M7.6
Slash commands and user-facing extension points
795 words · about 5 min
Concept
Extension points so far serve the model. But some capabilities belong to the human: switching a mode, showing status, clearing state. Routing those through the model is wasteful and unreliable — you pay a request and its latency, and the model may decline, mangle, or narrate the action.
docs/capability-seams.md states the purpose of ctx.commands in one line: "Plugins register direct human commands without sending invocations to the model."
Mechanism
From packages/interaction/commands/README.md, a command is registered with ctx.commands.register(definition):
interface CommandDefinition {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/**
* Whether `command/run` records `rawInput`. Defaults to true. A command
* whose domain event owns the payload sets this false to avoid duplicating
* that payload in the session log.
*/
readonly recordInput?: boolean
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}The handler receives the exact target agent, the rawInput after the name (retaining separator whitespace — "consumers own their command-specific grammar"), durably admitted image attachments, a commandId already written to the log, and an AbortSignal owned by the dispatching UI. It returns:
type CommandResult =
| { readonly kind: 'success'; readonly text?: string; readonly sourceEventSeq?: number }
| { readonly kind: 'error'; readonly text: string }Discovery hands adapters handler-free immutable CommandDescriptors. That is deliberate: a UI that could reach a handler could invoke it outside the lifecycle that logs and bounds it.
Scoping mirrors skills and tools: "A plain-context registration is global. A command-producing plugin mounted beneath agent.ctx declares its own commands injection and creates an exact agent-scoped definition; it shadows a global definition with the same name." The README names the payoff: "This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service." The loop knows nothing about commands, and commands still resolve per agent.
Three design decisions with transferable reasoning:
Duplicate names fail within one layer. Silently shadowing would make a plugin's command vanish for reasons no log records.
Results never enter model history. "Results are rendered directly by the adapter and never enter model history … The registry never submits rawInput to the agent implicitly." Zero token cost, zero KV-cache effect. A command producer that wants model-visible work must schedule it explicitly through the Agent — dsh-plan-mode submits the optional message in /plan [message] after selecting the mode — and then owns that message contract.
The lifecycle is logged even though the result is not. command/run before the handler and command/done at settlement, "log-only" and standalone: "no turn wraps them." A thrown or aborted handler settles as kind: 'error'. This is the Module 4 invariant read carefully — the rule is model-visible ⟺ logged, so something non-model-visible need not be logged for correctness, but is logged anyway for auditability. Knowing a human typed /plan off at a certain point is essential to reconstructing why a session behaved as it did.
recordInput: false handles the duplication case: when an authoritative domain event already owns the payload, command/run omits args rather than storing it twice.
The second user-facing extension point
Skills carry userInvocable in SkillInvocationPolicy, so a skill can be human-runnable, model-runnable, both, or (uselessly) neither. Together the surfaces cover the matrix: commands are always human-initiated and never model-visible; tools are always model-initiated; skills choose per skill.
Tradeoffs
Commands are text-only: "Only unstructured text input — forms, completion schemas, and typed arguments remain command-owned parsing concerns." Every producer writes its own parser, and they will be inconsistent. A typed-argument schema would fix that at the cost of a much larger registry contract, and dsh has deferred it.
Cancellation is cooperative: "dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems." A hung command's UI unblocks while its side effects continue.
And discovery does not scale by itself. list(agent) returns everything registered; with fifty plugins the palette is unusable without grouping the registry does not provide.
How others differ
Claude Code supports Markdown-file slash commands in .claude/commands/ whose content is submitted to the model as a prompt — the opposite choice. That is far more accessible (any user can author one with no code) and strictly more expensive (every invocation is a model turn) and less reliable (the model may not comply). dsh's commands are code-registered and never reach the model: precise, auditable, free — and requiring a plugin to author. Neither is wrong; they serve different authors. A mature harness arguably wants both, and dsh currently has only the code-registered kind.
Check yourself
1 question on M7.6