refactor(mode): move exit-tool visibility onto the registry restriction layer; register /mode; drop unconsumed surfaces
The master merge brought the tool registry's per-scope restriction layer (tools.restrict), which makes dsh-mode's prepend assemble filter a duplicate enforcement shape: it re-implemented the registry's SDK-section rendering (renderToolsSdk + the RUN_CODE_NAME exclusion) and hid the exit tool from prompts only — dispatch stayed open and the execute-time re-check was the real gate. The service now reconciles a per-agent deny restriction on agent.ctx at agent/created and at every boundary flush, so wire schemas, the Code Mode tools:sdk section, AND dispatch resolve exit_plan_mode through the one registry view (a default-mode call answers UNKNOWN_TOOL, byte-identical to a no-dsh-mode deployment). The execute-time folded-mode re-check stays as defense in depth for a direct foreign mode/set append no boundary has reconciled yet. Two zero-consumer surfaces removed per the pre-release stance: - AgentOptions.mode creation seeding (declaration merge + agent/created listener); a caller selects through set() before the first turn, and the deferred subagent inheritance returns together with its consumer. - The dropped-definition boundary notice (droppedNoticed + narration): custom mode definitions have no production consumer, so nothing can be dropped; fold-to-default degradation is unchanged. The stdio removal had left plan mode ACP-only while docs still claimed a /mode command. dsh-mode now registers /mode on the plugin-owned command registry through an optional ctx.inject(['commands']) child (type-only peer edge on dsh-commands), so the TUI and the ACP slash-command surface both gain it; examples/tui-agent composes dsh-mode. ACP/TUI expected outputs refreshed keyless for the available_commands_update delta. Docs updated in place (mode READMEs, the plan-mode Agent Note's realization sections); catalogs and graphs regenerated.
This commit is contained in:
@@ -22,7 +22,7 @@ The model leaves plan mode through the **`exit_plan_mode`** tool: its single arg
|
||||
|
||||
### A plan-mode session end to end
|
||||
|
||||
The user switches the session to plan mode — the ACP mode picker or the stdio `/mode plan` — and from the next turn every request ships the plan guidance section and the `exit_plan_mode` tool.
|
||||
The user switches the session to plan mode — the ACP mode picker or the `/mode plan` command in a terminal front door — and from the next turn every request ships the plan guidance section and the `exit_plan_mode` tool.
|
||||
|
||||
The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option.
|
||||
|
||||
@@ -49,7 +49,7 @@ A definition is exactly `{ section }` — there is deliberately no per-mode tool
|
||||
|
||||
### In the terminal
|
||||
|
||||
The stdio app gains `/mode` (print the current and available modes) and `/mode <name>` (switch + banner — a command line, never sent to the model, and reserved even while a question prompt is active: a command is never recorded as an answer). The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the stdio provider's one-prompt-owns-stdin queue that `ask_user_question` already uses.
|
||||
Terminal front doors get `/mode` through the plugin-owned command registry (`@deepseek-ai/dsh-commands`): `dsh-mode` registers the command when a commands service is composed — bare `/mode` prints the current and available modes, `/mode <name>` records the switch (a command line, never sent to the model). The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the composed user-interaction provider's prompt queue that `ask_user_question` already uses.
|
||||
|
||||
### Over ACP
|
||||
|
||||
@@ -57,7 +57,7 @@ The mode PICKER is this package's surface: `session/new`/`session/load` advertis
|
||||
|
||||
### For agent creators
|
||||
|
||||
`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. A creator seeds a child's initial mode through `AgentOptions.mode` (`AgentOptions` is merge-extensible; `dsh-mode` declares the optional field). There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md).
|
||||
`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. There is no creation-time mode option — a caller selects through `set()` before the first turn, which flushes identically. There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md).
|
||||
|
||||
## Detailed design
|
||||
|
||||
@@ -86,13 +86,13 @@ The one-field shape is deliberate minimalism, not the final vocabulary: a per-to
|
||||
|
||||
`foldMode(events)` is pure (exported for reconstructors and tests); the service tracks it per session with a lazy cursor in a `WeakMap<Session, { cursor, mode }>` — O(new events) per read, never invalidated, because the log is append-only and `mode/set` is not a surface node (compaction cannot rewrite it). `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET (a picker's exit-to-default must be a valid write) — drops a no-op (target equals pending ?? current), and otherwise records the intent in a `WeakMap<Session, string>` — it cannot append immediately, because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn.
|
||||
|
||||
Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, `agent/turn-continuation` after each step closed before the next assembly, and both sit outside the step's tool-execution window AND outside any log emit (post-commit `session/event` observers are observe-only; an append from one would re-enter the publishing append), so the executions of a step always run under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). Seeding rides `agent/created`: `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows.
|
||||
Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, `agent/turn-continuation` after each step closed before the next assembly, and both sit outside the step's tool-execution window AND outside any log emit (post-commit `session/event` observers are observe-only; an append from one would re-enter the publishing append), so the executions of a step always run under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq).
|
||||
|
||||
### The soft layer: a computed section and a narrow post-`next()` filter
|
||||
|
||||
A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces on the next step as a complete changed `request/header`, so every mode transition is an attributable log fact. The section is static per mode and the plan itself stays in the conversation (messages and tool args, already in context), so a mode does not add per-step prompt churn — re-injecting plan state into every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary and would only burn prefix cache.
|
||||
|
||||
The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100–199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The same listener carries the ONE tool rule this plugin has, deliberately narrow (`prepend: true`, so it wraps outside every append-registered listener's post-`next()` mutation): `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. Everything else passes through untouched: which tools a mode admits is not this plugin's decision. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the same visibility rule; the default mode re-renders it too, hiding only the exit binding, keeping a default-mode Code Mode assembly byte-identical to a deployment that never loaded `dsh-mode`. There is NO `tools/pre-execute` listener: a mode gates nothing (the exit tool's own folded-mode recheck rejects an out-of-plan call), and the registry's `ask` vocabulary stays free for genuine permission gating — the exit review is a question with options and feedback, not a permission, so it lives inside the tool's own execution over the user-interaction seam.
|
||||
The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100–199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The ONE tool rule this plugin has rides the registry's scoped restriction layer, not an assemble wrapper: outside plan the service holds a per-agent deny restriction over `exit_plan_mode` (`agent.ctx.tools.restrict`), reconciled with the fold at `agent/created` and at every boundary flush, so wire schemas, the Code Mode `tools:sdk` section, and dispatch all resolve the tool through the one registry view — a default-mode assembly is byte-identical to a no-`dsh-mode` deployment, and a default-mode dispatch answers `unknown tool` at the registry boundary rather than relying on prompt omission. Everything else passes through untouched: which tools a mode admits is not this plugin's decision. There is NO `tools/pre-execute` listener: the restriction plus the exit tool's own folded-mode recheck (defense in depth for a direct foreign `mode/set` append no boundary has reconciled yet) cover the exit tool, and the registry's `ask` vocabulary stays free for genuine permission gating — the exit review is a question with options and feedback, not a permission, so it lives inside the tool's own execution over the user-interaction seam.
|
||||
|
||||
### `exit_plan_mode`
|
||||
|
||||
@@ -102,7 +102,7 @@ Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent
|
||||
|
||||
### Dependencies and surfaces
|
||||
|
||||
`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`), and depends on no UI package. Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. The stdio app adds a `/mode [name]` line-handler branch — the exit review needs nothing there, because the stdio user-interaction provider already owns the prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today.
|
||||
`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, and reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`); its only UI-facing edges are optional type-only peers (`dsh-commands` for `/mode`). Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. Terminal front doors need no mode-specific code: `dsh-mode` itself registers `/mode` on the command registry when one is composed (an optional type-only peer edge on `dsh-commands`), and the exit review rides the composed user-interaction provider's prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today.
|
||||
|
||||
### The recorded scenario and the harness op
|
||||
|
||||
@@ -114,7 +114,7 @@ No new cordis event is declared (`mode/set` rides `session/event`; the listeners
|
||||
|
||||
## Deferred
|
||||
|
||||
Each behind its own decision: subagent mode inheritance via a forwarded `AgentOptions.mode` (the option field itself ships), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
|
||||
Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
|
||||
|
||||
The canonical [`examples/acp-agent`](../../../../examples/acp-agent/) composition mounts the mode and question-tool plugins on the full ACP coding server; plan mode is an additive session feature, not a second server profile. Its existing snapshot suite owns the landed scenarios: `plan-mode` (the pinned-header arc — plan-shaped initial header, a real `cat` inside plan, scripted elicitation approve, the pure-removal header-delta, a real edit) and `plan-mode-reject` (keep-planning feedback carried verbatim in the corrective `isError`), beside the keyless `modes-advertise` wire golden.
|
||||
|
||||
@@ -126,13 +126,13 @@ Behavioral clarifications of the chosen design; rejected designs live in [Altern
|
||||
|
||||
**When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has.
|
||||
|
||||
**What happens on resume when the config no longer defines the folded mode?** One read-path rule closes the gap: a folded mode name the current config no longer defines behaves as the default mode plus one boundary notice naming the dropped definition — never a silent substitute restriction, never a bricked session. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds.
|
||||
**What happens on resume when the config no longer defines the folded mode?** One read-path rule closes the gap: a folded mode name the current config no longer defines behaves as the default mode — never a silent substitute restriction, never a bricked session. (A dedicated boundary notice narrating the dropped definition shipped first and was removed as speculative: custom mode definitions have no production consumer yet, so nothing can currently be dropped; it returns with its first consumer if silent degradation proves confusing.) `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds.
|
||||
|
||||
**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling.
|
||||
|
||||
**Why is there no per-mode tool allowlist?** Because "which tools are safe in a planning mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger), a mode restrains by its section and the exit review; the exposure is an accepted cost ([Consequences](#consequences)).
|
||||
|
||||
**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode unless its creator seeds `AgentOptions.mode`; automatic forwarding by subagent providers is deferred ([Deferred](#deferred)).
|
||||
**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode; a creation-time mode option and automatic forwarding by subagent providers are deferred together ([Deferred](#deferred)).
|
||||
|
||||
**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`.
|
||||
|
||||
@@ -185,4 +185,4 @@ What holds now, pinned by the unit, protocol, and snapshot tiers:
|
||||
- `exit_plan_mode`'s approve path flips the mode and drops the plan surface on the next step; the keep-planning path returns the corrective `isError` carrying the user's feedback and stays in plan mode; the ACP `session/set_mode` round-trip updates `current_mode_update`, and the exit review prompts through each surface's user-interaction provider.
|
||||
- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row.
|
||||
|
||||
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log (every call attributable under the plan-shaped header), and the independent enforcement axes (the sandbox mode, the approval policy, the fs stack's own policies) are the containment surface, and hardening planning means setting those knobs, not widening the mode; the two removed enforcement shapes and their restart trigger (effects self-declaration) are archived in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). The mode filter prepends and hides only the exit tool; a listener that ALSO prepends after `dsh-mode` loads could re-show it — a re-shown exit tool self-rejects outside plan, so the residual is cosmetic. The ACP mode surface carries both this stack's picker and the sandbox stack's config options under the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq), recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md). The ACP spec's draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates to a config-option select mechanically — the mode state and every policy surface are wire-agnostic — accepted.
|
||||
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log (every call attributable under the plan-shaped header), and the independent enforcement axes (the sandbox mode, the approval policy, the fs stack's own policies) are the containment surface, and hardening planning means setting those knobs, not widening the mode; the two removed enforcement shapes and their restart trigger (effects self-declaration) are archived in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). The exit tool's visibility is a registry restriction, so an assemble listener cannot re-show it; the executor resolves dispatch through the same view. The ACP mode surface carries both this stack's picker and the sandbox stack's config options under the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq), recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md). The ACP spec's draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates to a config-option select mechanically — the mode state and every policy surface are wire-agnostic — accepted.
|
||||
|
||||
Reference in New Issue
Block a user