docs(rfc): restructure plan-mode RFC and drop its Chinese counterpart
Reorganize along Problem / Proposal / High-level API / Detailed design / Roadmap / FAQ / Prior art / Alternatives / Acceptance / Risks. The product survey and Pi failure shapes move to Prior art; user-facing behavior (walkthrough, cordis.yml config, stdio, ACP, ctx.modes) gets its own High-level API section; FAQ collects behavioral clarifications of the chosen design, disjoint from Alternatives (rejected designs) and Risks (accepted costs). The zh counterpart and its pairing metadata are removed; the RFC is English-only (not in the required-pairs manifest).
This commit is contained in:
@@ -2,49 +2,63 @@
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-07-plan-mode.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness has no way to put an agent into a reduced-authority working state. The canonical feature that needs one is plan mode — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority only through an explicit approval. [The extension cookbook](../../../cookbook/extension-cookbook.md) already reserves the row ("Plan mode — `tools/pre-execute` (deny writes) + a mode prompt section"), and [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records session modes as a known gap both reference adapters ship (Claude's plan auto-mode, Codex's read-only / agent / full-access presets). Neither says where the mode STATE lives, how it survives resume and fork, or how its model-visible consequences stay honest with the session log.
|
||||
The harness has no way to put an agent into a reduced-authority working state. The canonical feature that needs one is plan mode — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority only through an explicit approval. [The extension cookbook](../../../cookbook/extension-cookbook.md) already reserves the row ("Plan mode — `tools/pre-execute` (deny writes) + a mode prompt section"), and [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records session modes as a known gap both reference adapters ship. Neither says where the mode STATE lives, how it survives resume and fork, or how its model-visible consequences stay honest with the session log.
|
||||
|
||||
A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere: a low-authority tool policy, a plan artifact, an approval moment, an execution-state switch, and durable state. Four of the five already exist here as gated infrastructure: what the model is TOLD it can do is shaped per step at [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) and whatever ships is logged as `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); what can RUN is gated at `tools/pre-execute` with typed decisions ([interception seams](../../implemented/feature/2026-06-30-interception-seams.md)); the approval moment is the `ask` vocabulary, serviced by the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`, in flight on `feat/sandbox-support` as this is written — link it on merge); durable per-agent facts are `SessionEventMap` members ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing fifth is the mode itself: a named, durable, per-agent policy state the policy listeners can read.
|
||||
|
||||
The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. Each of those holes closes structurally here — but only if the mode is logged session state, not plugin-private memory.
|
||||
Every shipped plan mode decomposes into the same five parts — a low-authority tool policy, a plan artifact, an approval moment, an execution-state switch, and durable state ([Prior art](#prior-art) carries the survey). Four of the five already exist here as gated infrastructure: what the model is TOLD it can do is shaped per step at [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) and whatever ships is logged as `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); what can RUN is gated at `tools/pre-execute` with typed decisions ([interception seams](../../implemented/feature/2026-06-30-interception-seams.md)); the approval moment is the `ask` vocabulary, serviced by the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`, in flight on `feat/sandbox-support` as this is written — link it on merge); durable per-agent facts are `SessionEventMap` members ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing fifth is the mode itself: a named, durable, per-agent policy state the policy listeners can read.
|
||||
|
||||
## Proposal
|
||||
|
||||
The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent policy state: mode definitions — which tools stay visible, what guidance section renders — are deployment config, and the mode IN FORCE for an agent is session state, folded from its log. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/` (a new top-level group, the `packages/approval/` shape), owns the event vocabulary, a thin `ctx.modes` service, and every policy listener; the loop does not change. `plan` is the only shipped definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now.
|
||||
|
||||
### The mode state is a session event
|
||||
The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of policy — no section, no filtering, no gate — so an agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads `dsh-mode`, which keeps every existing snapshot golden stable and makes the plugin safe to compose unconditionally.
|
||||
|
||||
`dsh-mode` declaration-merges **`mode/set`** into `SessionEventMap`: a log-only, non-surface event carrying `{ mode: string }`, whole-value-replace semantics like `todo/write`. A pure `foldMode(events)` returns the mode in force — the last `mode/set`, or the default mode when none exists — and the plugin caches the fold per session with a lazy cursor (the `foldRequestHeader` idiom). Because the event is log-only it never enters the model transcript, and because it is not a surface node compaction can never shadow it: the fold sees the whole log on live sessions, resume, and fork alike. Per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md) the log is the fact channel, so mode state needs no live `agent/*` mirror — UIs read `mode/set` off `session/event`.
|
||||
Enforcement is two layers that cover each other. The **soft** layer is a `system-prompt/assemble` listener that filters the tool schemas down to the mode's allowlist and appends the mode's guidance section — every transition therefore surfaces as an attributable `request/header-delta` on the next step, keeping the [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) invariant green by construction. The **hard** layer is a `tools/pre-execute` listener that denies, deny-by-default against the same allowlist, any call the mode does not permit — so a hallucinated call to a still-registered tool, or a schema re-widened by a foreign assemble listener, still cannot run.
|
||||
|
||||
The default mode is the absence of policy: no section, no filtering, no gate. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads `dsh-mode` — which keeps every existing snapshot golden stable and makes the plugin safe to compose unconditionally.
|
||||
The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan a durable log artifact, and the hard gate turns exactly this call into the approval moment (`ask`, routed by the approval seam) — approval flips the logged mode back to the default, rejection becomes the corrective error that keeps the model planning. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed.
|
||||
|
||||
### Two layers of enforcement
|
||||
## High-level API
|
||||
|
||||
**Soft — what the model sees.** A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in plan mode, filters `assembly.tools` down to the mode's allowlist and appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces as a `request/header-delta` on the next step, so every mode transition is an attributable, diffable log fact and the [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) invariant stays green by construction. 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 — the pi-style "re-inject the plan file every request" hack is unnecessary and would only burn prefix cache.
|
||||
### A plan-mode session end to end
|
||||
|
||||
**Hard — what can run.** A `tools/pre-execute` listener denies, with a mode-naming reason that steers the model back to planning, any call outside the mode's allowlist. This layer is not redundant with the filter: [`ToolRegistry.execute()`](../../../../packages/core/tools/README.md) dispatches any registered tool by name, so a model hallucinating a filtered-out (or MCP-registered) tool would still run it without the gate. Deny-by-default against the allowlist also means the two layers cover each other — a peer `assemble` listener that re-widens the schema set cannot make the widened tools executable. An agent-less execution (no session to fold) passes through, mirroring the approval seam's agent-less degrade.
|
||||
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 filtered read-only toolset plus the plan-mode guidance section.
|
||||
|
||||
### Mode changes and turn enclosure
|
||||
The model explores and designs with what remains; if it attempts a write anyway, the gate denies with a reason naming the mode and pointing at `exit_plan_mode`, and the transcript keeps planning.
|
||||
|
||||
Two writers flip the mode. A **tool** (`exit_plan_mode`) appends `mode/set` from inside its own execution — already turn-enclosed, the `todo/write` path. A **user** flips it through `ctx.modes.set(agent, mode)` (a stdio command, ACP `session/set_mode`), and that path cannot append immediately: [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md), and an idle agent has no open turn. The service therefore records a pending intent and flushes it as the first append after the next `turn/start`. Sequencing makes this correct for the request the turn sends: the loop assembles the prompt after the turn opens and before each step, so a flush at `turn/start` is folded by step 1's assembly, while a mid-turn flip lands at the next boundary and takes effect on the following step — the same "applies to subsequent requests" semantics every surveyed product ships. A user flip is also **narrated**: when the flushed mode differs from the fold at the last `request/header`, the service appends one coalesced notice in the same frame ("The user switched this session to plan mode."), so a net-zero flip sequence narrates nothing, a tool-driven exit narrates through its own tool result instead, and a mode set before the first turn narrates nothing (the section is the state statement) — the boundary-narration principle of the in-flight env-state proposal (`docs/rfc/proposed/feature/2026-07-06-env-state-visibility.md`): a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has. The cost is honest and bounded: a pending intent set while idle is lost if the process dies before the next turn (the UI that set it still holds it and re-applies); promoting user flips to a durable idle-time fact would need a generalized idle-record primitive, which stays out of scope until the loss proves real.
|
||||
When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the UI renders the plan as the call card and the approval prompt attaches to that same card, so what the human approves is exactly the logged artifact.
|
||||
|
||||
### The plan artifact and the exit tool
|
||||
On approve, the tool flips the logged mode back to the default: the next step runs with the full toolset and the widening `request/header-delta` in the log, and execution tracking from there is already `todo_write`'s job. On reject, the model receives the registry's "user rejected" error, revises, and re-presents.
|
||||
|
||||
The model-facing **`exit_plan_mode`** tool closes the loop, visible only in plan mode (the assemble filter adds it there and drops it elsewhere; the pre-execute gate denies it outside plan mode). Its single argument is the plan text — which makes the plan a durable, replayable log artifact riding the ordinary `tool/call` event, with no parallel plan-file store to invent or drift. Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: a `generic` call card titled by the plan's first heading with the plan markdown as content, and a `generic` result card. The approval moment is not new machinery: the mode gate returns `ask` for this one call, the approval seam routes it (ACP: `session/request_permission` attached to the streamed call, one-shot allow/reject), `allowed-once` lets the tool body append `mode/set` back to the default mode, and every other outcome becomes the corrective `isError` that tells the model to keep planning. Execution tracking after approval is already covered by `todo_write`. A deployment that composes no answerer keeps a safe but manual shape: the gate's `ask` resolves `unavailable` and denies (the seam's fail-closed default), so the exit degrades to the user toggling modes — never to an unapproved exit.
|
||||
### Deployment configuration
|
||||
|
||||
### Package shape
|
||||
Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The shipped `plan` definition works with zero config; overriding it, or adding a mode, is a config entry:
|
||||
|
||||
`dsh-mode` is one product package, not a capability-seam trio — there is no swappable implementation; the variable parts are config values and the fixed listeners ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md): don't split preemptively; the approval seam made the same call). It is more than an [fs-policy-style](../../../../packages/fs/fs-policy/README.md) pure event-gate plugin only because UIs need a call surface: `ctx.modes` exposes `list()` (the configured definitions, for a mode picker), `get(agent)` (the fold plus any pending intent), and `set(agent, mode)` (validate against config, record intent, flush at the boundary). Everything else participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer.
|
||||
```yaml
|
||||
- id: mode
|
||||
name: '@deepseek-ai/dsh-mode'
|
||||
config:
|
||||
modes:
|
||||
plan:
|
||||
section: |
|
||||
You are in plan mode: explore and design, then present the
|
||||
plan for approval through exit_plan_mode.
|
||||
tools: [read, todo_write, web_search, web_fetch, exit_plan_mode]
|
||||
```
|
||||
|
||||
Mode definitions are validated plugin Config — per repo convention (changeable from `cordis.yml`, no code edit): each names its tool allowlist and its section text, and `plan`'s shipped default allowlist is the read-only surface (`read`, `todo_write`, `web_search`/`web_fetch`, `exit_plan_mode`) with `bash` and `subagent` excluded until the sandbox family can actually confine them. `AgentOptions` is merge-extensible, so `dsh-mode` declares an optional `mode` field: a creator (or a subagent provider forwarding its parent's mode) seeds the child's initial mode, applied through the same pending-intent flush on the first turn.
|
||||
`plan`'s shipped default allowlist is the read-only surface (`read`, `todo_write`, `web_search`/`web_fetch`, `exit_plan_mode`) with `bash` and `subagent` excluded until the sandbox family can actually confine them — a deployment that accepts the risk widens its own config today. `default` is reserved (the absence of policy) and rejected as a key; an unknown mode name fails validation loudly at `set()` time.
|
||||
|
||||
### Protocol and UI surfaces
|
||||
### In the terminal
|
||||
|
||||
The stdio app gains a mode toggle command, a banner line, and a readline answerer on the approval waterfall, so the exit approval prompts right in the terminal (riding the in-flight user-interaction stdio provider's one-prompt-owns-stdin queue where that seam is mounted — a yes/no confirm is a degenerate single-select — and raw readline otherwise). On ACP, the mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes: they belong to `session/set_config_option`, and the in-flight env-state proposal's config-phase sketch, which currently routes `set_mode` to env facts, is the ONE overlap between the two proposals — the division proposed here is picker-to-modes / knobs-to-config-options, a mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode, and whichever proposal lands second amends its wiring to match. The exit tool's approval needs no new ACP work at all — it rides the approval seam's answerer.
|
||||
The stdio app gains `/mode` (print the current and available modes) and `/mode <name>` (switch + banner — a command line, never sent to the model). The exit approval prompts right in the terminal: a readline answerer on the approval waterfall, riding the in-flight user-interaction stdio provider's one-prompt-owns-stdin queue where that seam is mounted (a yes/no confirm is a degenerate single-select) and raw readline otherwise.
|
||||
|
||||
### Over ACP
|
||||
|
||||
The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool's approval needs no new ACP work at all — it rides the approval seam's `session/request_permission`, attached to the already-streamed call. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)).
|
||||
|
||||
### 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 config and records the boundary-applied intent. 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).
|
||||
|
||||
## Detailed design
|
||||
|
||||
@@ -72,14 +86,20 @@ The allowlist is deliberately the degenerate form of a future per-tool decision
|
||||
|
||||
### The fold, the service, and the flush
|
||||
|
||||
`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). `ctx.modes` (a cordis Service, key `modes`) exposes `list()` — the synthetic `default` entry plus the configured definitions, for pickers — `get(agent): { current, pending? }`, and `set(agent, mode)`, which validates the name against config, drops a no-op (target equals pending ?? current), and otherwise records the intent in a `WeakMap<Session, string>`. A contained `session/event` listener ([defensive patterns](../../../defensive-patterns.md): a policy plugin must not kill the feed) flushes the pending intent as a `mode/set` append on the next `turn/start` or `step/end` — both sit outside the step's tool-execution window, so the executions of a step always run under the mode its assembly folded — and, when the flushed mode differs from the fold at the last `request/header`, appends the one coalesced `context/message` notice in the same frame. Seeding rides `agent/created`: a declaration-merged `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 — while a fork child needs no mechanism at all (the parent's `mode/set` is inside the seeded prefix). One read-path rule closes the resume-with-edited-config 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, and a resumed log answers to the config it finds.
|
||||
`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 config, 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.
|
||||
|
||||
A contained `session/event` listener ([defensive patterns](../../../defensive-patterns.md): a policy plugin must not kill the feed) flushes the pending intent as a `mode/set` append on the next `turn/start` or `step/end` — both sit outside the step's tool-execution window, so the executions of a step always run under the mode its assembly folded — and, when the flushed mode differs from the fold at the last `request/header`, 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.
|
||||
|
||||
### The soft layer: a computed section and a post-`next()` filter
|
||||
|
||||
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 tool filter is a `system-prompt/assemble` waterfall listener that wraps: it awaits `next()` and filters the RETURNED assembly's `tools`, so additions made anywhere inside its wrap are covered. The filter enforces one rule in every mode: `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which is also what keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. In a non-default mode it additionally intersects with the mode's allowlist.
|
||||
A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, filters `assembly.tools` down to the mode's allowlist and appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces as a `request/header-delta` on the next step, so every mode transition is an attributable, diffable 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 tool filter wraps: it awaits `next()` and filters the RETURNED assembly's `tools`, so additions made anywhere inside its wrap are covered. The filter enforces one rule in every mode: `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which is also what keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. In a non-default mode it additionally intersects with the mode's allowlist.
|
||||
|
||||
### The hard layer: the gate
|
||||
|
||||
The gate denies, with a mode-naming reason that steers the model back to planning, any call outside the mode's allowlist. This layer is not redundant with the filter: [`ToolRegistry.execute()`](../../../../packages/core/tools/README.md) dispatches any registered tool by name, so a model hallucinating a filtered-out (or MCP-registered) tool would still run it without the gate. Deny-by-default against the allowlist also means the two layers cover each other — a peer `assemble` listener that re-widens the schema set cannot make the widened tools executable.
|
||||
|
||||
```text
|
||||
tools/pre-execute: no exec.agent → next() // agent-less calls have no session to fold
|
||||
folded mode = default → next()
|
||||
@@ -90,15 +110,17 @@ tools/pre-execute: no exec.agent → next() // agent-less calls have no
|
||||
otherwise → deny // reason names the mode and points at exit_plan_mode
|
||||
```
|
||||
|
||||
The gate folds the LOGGED mode only, never the pending intent — enforcement judges by the same state the request's header shipped under. Because the `ask` is produced here and resolved by `ToolRegistry.execute()` through `ctx.approval`, `dsh-mode` takes no dependency on the approval package; a deployment without the seam gets the registry's fail-closed degrade.
|
||||
The agent-less pass-through mirrors the approval seam's agent-less degrade. The gate folds the LOGGED mode only, never the pending intent — enforcement judges by the same state the request's header shipped under. Because the `ask` is produced here and resolved by `ToolRegistry.execute()` through `ctx.approval`, `dsh-mode` takes no dependency on the approval package; the no-answerer degrade is pinned in the [FAQ](#faq).
|
||||
|
||||
### `exit_plan_mode`
|
||||
|
||||
`defineTool` with one required `plan: string` argument. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), re-checks the folded mode as defense in depth, appends `mode/set { mode: 'default' }` in-turn, and returns a short confirmation; the next step's assembly restores the full toolset and logs the widening `request/header-delta`. `presentCall` is a `generic` card carrying the plan markdown as content — the approval prompt attaches to this already-streamed call by `callId`, so what the human approves is exactly the logged artifact. A rejection reaches the model as the registry's "user rejected" `isError`, and it revises and re-presents.
|
||||
`defineTool` with one required `plan: string` argument — the plan is thereby a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), re-checks the folded mode as defense in depth, appends `mode/set { mode: 'default' }` in-turn (a tool execution is already turn-enclosed — the `todo/write` path), and returns a short confirmation; the next step's assembly restores the full toolset and logs the widening `request/header-delta`.
|
||||
|
||||
Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card — the approval prompt attaches to this already-streamed call by `callId`. The approval moment is not new machinery: the gate returns `ask` for this one call, the approval seam routes it (one-shot allow/reject), `allowed-once` lets the tool body run, and every other outcome becomes the registry's "user rejected" `isError` that tells the model to revise and re-present.
|
||||
|
||||
### Dependencies and surfaces
|
||||
|
||||
`dsh-mode` peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, `dsh-system-prompt` (manifest shape mirrors `dsh-tool-todo`), injects `['tools', 'systemPrompt']`, and depends on neither the approval package nor any UI. The stdio app adds a `/mode [name]` line-handler branch (print or switch + banner, never sent to the model) and the readline answerer for its own agent. The ACP wire mapping is pinned in Protocol and UI surfaces; 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`, `dsh-system-prompt` (manifest shape mirrors `dsh-tool-todo`), injects `['tools', 'systemPrompt']`, and depends on neither the approval package nor any UI. 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 and the readline answerer for its own agent. 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
|
||||
|
||||
@@ -112,7 +134,7 @@ No new cordis event is declared (`mode/set` rides `session/event`; the listeners
|
||||
|
||||
Plan mode is one feature and lands as one. An agent that can be locked into planning but has no sanctioned way to propose leaving it is not a smaller version of the feature — it is a different and worse one, where every plan ends with the model asking the user to flip a switch it cannot see. The two stages below are therefore build-and-review order for one stacked landing ([stacked-review guide](../../../cookbook/responding-to-pr-review-on-a-stack.md)): stage 2 stacks on stage 1 and the stack merges together; neither stage is a shippable milestone on its own.
|
||||
|
||||
The one hard prerequisite is the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`): the exit approval is its `ask` routing end to end. It is already implemented on `feat/sandbox-support`, so the coupling is merge order, not unbuilt work — this stack bases on that branch until it lands on master. The wider in-flight neighborhood is convergent, not conflicting: the sandbox-escalation branch ships the first live approval composition (its example and scripted-answer harness are the precedent our recorded scenarios follow), the env-state proposal pins the same fold-from-log + boundary-application idiom for environment facts (its `session/set_mode` config-phase sketch is the single coordination point, resolved in Protocol and UI surfaces), and the user-interaction seam supplies the stdio answerer's stdin discipline where mounted.
|
||||
The one hard prerequisite is the approval seam (`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`): the exit approval is its `ask` routing end to end. It is already implemented on `feat/sandbox-support`, so the coupling is merge order, not unbuilt work — this stack bases on that branch until it lands on master. The wider in-flight neighborhood is convergent, not conflicting: the sandbox-escalation branch ships the first live approval composition (its example and scripted-answer harness are the precedent our recorded scenarios follow), the env-state proposal pins the same fold-from-log + boundary-application idiom for environment facts (its `session/set_mode` config-phase sketch is the single coordination point, resolved in the [FAQ](#faq)), and the user-interaction seam supplies the stdio answerer's stdin discipline where mounted.
|
||||
|
||||
### Stage 1 — the mode core
|
||||
|
||||
@@ -124,6 +146,30 @@ The `dsh-mode` package: `mode/set` + `foldMode`, the assemble filter and mode se
|
||||
|
||||
Deferred beyond this landing, each behind its own decision: subagent mode inheritance via a forwarded `AgentOptions.mode` (the option field itself ships in stage 1), per-tool `ask` policies inside mode definitions (an OpenCode-style "bash asks in plan mode"), preset modes beyond `plan` (read-only, accept-edits), sandbox-backed bash confinement in plan mode, and the idle-record primitive if pending-intent loss proves real.
|
||||
|
||||
## FAQ
|
||||
|
||||
Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Risks](#risks).
|
||||
|
||||
**When does a user's mode flip take effect?** At the next turn boundary: `set()` records a pending intent, the service flushes it as the first append after the next `turn/start`, and the loop assembles the prompt after the turn opens and before each step — so step 1 already folds it. A mid-turn flip lands at the next boundary and takes effect on the following step. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships.
|
||||
|
||||
**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 if a deployment composes no approval answerer?** Plan mode stays safe but manual: the gate's `ask` resolves `unavailable` and denies (the seam's fail-closed default), so the exit degrades to the user toggling modes — never to an unapproved exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user if that is denied — so it never thrashes against the gate.
|
||||
|
||||
**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 ([Roadmap](#roadmap)).
|
||||
|
||||
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs and belong to ACP's `session/set_config_option`; the division this proposal pins is picker-to-modes / knobs-to-config-options. The in-flight env-state proposal's config-phase sketch currently routes `session/set_mode` to env facts — the one overlap between the two proposals. A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
|
||||
|
||||
## Prior art
|
||||
|
||||
A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on.
|
||||
|
||||
The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex — whose plan feature itself is `/plan` — fills its list with its approval presets (read-only / agent / full-access). This is the surface [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records as the gap, and what sizes the vocabulary as named modes rather than a flag.
|
||||
|
||||
The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. Each hole closes structurally here, but only because the mode is logged session state rather than plugin-private memory: per-agent folded state replaces the contested global list, the hard gate closes the prompt-only hole, and a log-only non-surface event that compaction cannot shadow makes the re-injection hack unnecessary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Permission modes as the concept (the Claude Code shape).** One `permissionMode` fusing approval policy and tool policy. Here those are two axes with two owners: the approval seam owns "who answers this question", modes own "what surface does the model get". ACP models them as related but distinct (a mode may select an approval policy later — a mode definition gains a field, not a merger).
|
||||
@@ -132,7 +178,7 @@ Deferred beyond this landing, each behind its own decision: subagent mode inheri
|
||||
|
||||
**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling.
|
||||
|
||||
**Prompt-only plan mode (no hard gate).** The Pi failure shape: filtering schemas (or asking nicely) does not stop a dispatch of a still-registered tool. The pre-execute gate is the enforcement layer; the filter is UX and cache hygiene.
|
||||
**Prompt-only plan mode (no hard gate).** The Pi failure shape ([Prior art](#prior-art)): filtering schemas (or asking nicely) does not stop a dispatch of a still-registered tool. The pre-execute gate is the enforcement layer; the filter is UX and cache hygiene.
|
||||
|
||||
**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free.
|
||||
|
||||
@@ -140,7 +186,7 @@ Deferred beyond this landing, each behind its own decision: subagent mode inheri
|
||||
|
||||
**A plan-file store (`.plans/` directory).** A second durable home for what the log already carries replayably; a deployment wanting files can add a tool that writes them. One home per fact.
|
||||
|
||||
**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST, Claude Code's picker lists `plan` beside `acceptEdits`, and Codex — whose plan feature is `/plan` — fills its list with its approval presets (read-only / agent / full-access); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition.
|
||||
**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition.
|
||||
|
||||
**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: waterfall listeners compose by construction, and the deny-by-default hard gate makes filter-order races non-exploitable. Formalize only if real conflicts appear.
|
||||
|
||||
@@ -158,4 +204,4 @@ Deferred beyond this landing, each behind its own decision: subagent mode inheri
|
||||
|
||||
## Risks
|
||||
|
||||
A pending user flip set while idle is lost if the process dies before the next turn — accepted (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. Sibling-listener order is not deterministic, so a foreign assemble listener wrapping OUTSIDE the mode listener could re-widen filtered schemas — the filter runs on the assembly `next()` returns (so everything inside its wrap is covered), and the hard gate keeps anything re-widened non-executable; the residual cost is cosmetic (the model sees a tool it cannot use), accepted rather than mechanized. Plan mode's shipped allowlist excludes `bash` and `subagent`, which costs real exploration power (no `git log`, no read-only delegate) until the sandbox family and mode inheritance land — a deployment that accepts the risk can widen its own config today. The whole landing gates on the approval seam merging first — a deliberate schedule coupling accepted in place of shipping the mode core alone (an incomplete feature, per the roadmap); the seam is implemented on its branch, and this stack bases on it meanwhile. A deployment that composes no answerer keeps a safe but manual plan mode (`ask` → `unavailable` → deny), and the mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user if that is denied — so it never thrashes against the gate. Two in-flight proposals touch the ACP mode surface (this one and the env-state config phase): the picker-to-modes / knobs-to-config-options division in Protocol and UI surfaces is the proposed contract, landing order decides who wires `session/set_mode`, and the second lander owes the amendment. Branch-heavy policy code under the per-file 100% coverage gate is real work, accepted as the ACP bridge did.
|
||||
A pending user flip set while idle is lost if the process dies before the next turn — accepted (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. Sibling-listener order is not deterministic, so a foreign assemble listener wrapping OUTSIDE the mode listener could re-widen filtered schemas — the filter runs on the assembly `next()` returns (so everything inside its wrap is covered), and the hard gate keeps anything re-widened non-executable; the residual cost is cosmetic (the model sees a tool it cannot use), accepted rather than mechanized. Plan mode's shipped allowlist excludes `bash` and `subagent`, which costs real exploration power (no `git log`, no read-only delegate) until the sandbox family and mode inheritance land — a deployment that accepts the risk can widen its own config today. The whole landing gates on the approval seam merging first — a deliberate schedule coupling accepted in place of shipping the mode core alone (an incomplete feature, per the roadmap); the seam is implemented on its branch, and this stack bases on it meanwhile. Two in-flight proposals touch the ACP mode surface (this one and the env-state config phase): the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq) is the proposed contract, landing order decides who wires `session/set_mode`, and the second lander owes the amendment. Branch-heavy policy code under the per-file 100% coverage gate is real work, accepted as the ACP bridge did.
|
||||
|
||||
Reference in New Issue
Block a user