Conflict resolution beyond line merges: the ACP bridge's mode/set re-notify moved inside master's new containment finally (a throwing presenter must not desync the picker), and session/new keeps the modes advertisement beside master's plain object return. dsh-mode's boundary flush moved off the session/event feed onto the loop's interception seams (agent/prompt-submit / agent/turn-continuation): master's scoped dispatch made post-commit session observers observe-only — an append from one re-enters the publishing append — while the two waterfalls fire outside any log emit and bracket the same boundaries; mode.spec's boundary helper now dispatches those seams via agentEvents, and the containment tests stub only the mode/set append. Catalogs, doc graphs, and the module graph regenerated.
40 KiB
RFC: Plan mode — a logged per-agent session mode
Status: implemented
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 already reserves the row ("Plan mode — tools/pre-execute (deny writes) + a mode prompt section"), and the ACP feature matrix 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.
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 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 and whatever ships is logged as request/header* events (reconstructability); what can RUN is gated at tools/pre-execute with typed decisions (interception seams); the approval moment is a human answer over the user-interaction seam (ctx.userInteraction, the ask-user precedent); durable per-agent facts are SessionEventMap members (the todo/write precedent). The missing fifth is the mode itself: a named, durable, per-agent policy state the policy listeners can read.
Decision
The deliverable is plan mode. It ships as the first session mode — a named, logged, per-agent policy state: mode definitions — the guidance section the model sees, the sandbox access cap — 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 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, 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.
Enforcement is deliberately scoped to where an enforcer exists. The soft surface is a system-prompt/assemble listener that renders the mode's guidance section and shows the exit_plan_mode tool IFF the folded mode is plan — every transition therefore surfaces as an attributable request/header event on the next step (a delta when expressible, the full fallback snapshot otherwise), keeping the reconstructability invariant green by construction. The enforced surface is the mode's access cap on the bash seam: a bash/resolve-mode clamp that keeps every shell command inside the declared sandbox access, plus two cap-derived tools/pre-execute guards (the bash tools are withheld when no confining executor can honor the cap; sandbox escalation is denied while the cap holds). There is NO general tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects (Deferred); until then a mode's non-shell restraint is the section's guidance.
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 tool conducts the review itself through the user-interaction seam — a question with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. 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.
High-level API
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 model explores and designs — the bash tools clamped to a read-only sandbox by plan mode's access cap when the composition confines them, so exploration commands run for real while a write is denied by the sandbox itself; outside the shell, the section's guidance is what defers changes into the plan.
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 review question arrives through the user-interaction channel — approve, or keep planning, with free-text feedback welcome — so what the human reviews is exactly the logged artifact.
On approve, the tool flips the logged mode back to the default: the next step runs unclamped with the narrowing-back header event in the log, and execution tracking from there is already todo_write's job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents.
Deployment configuration
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:
- 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.
access: read-only
A definition is exactly { section, access? }. The shipped plan pairs its guidance section with access: read-only (the access cap); there is deliberately no per-mode tool list (FAQ). default is reserved (the absence of policy) and rejected as a key; an access outside the SANDBOX_MODES ladder — or any unknown definition key, a tools list included — fails validation at load; an unknown mode name fails loudly at set() time.
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.
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 review needs no new ACP work at all — it rides the elicitation flow the user-interaction ACP provider already drives, beside the already-streamed plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to session/set_config_option (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 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.
Detailed design
Vocabulary
'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface,
// whole-value replace — the last one in the log wins
DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable
The payload carries no reason/provenance field: a tool-driven flip sits next to its tool/call in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the reconstructability RFC made for header deltas (the in-flight env/state event carries a source precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no Branded<B>).
Config and the resolve step
interface ModeDefinition { section: string; access?: SandboxMode }
// prompt text; optional cap on the sandbox
// access shell commands run under while the mode holds
interface ModeConfig { modes?: Record<string, ModeDefinition> } // plan's built-in definition merged unless overridden
resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud:
// 'default' as a key rejected; access validated against
// the SANDBOX_MODES ladder; any unknown key rejected
The two-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions (Deferred), read here rather than re-declared per mode — the config shape must not need a migration when it arrives.
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). 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 and an idle agent has no open turn.
Contained listeners on the loop's interception seams (defensive patterns: 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. 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 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 request/header-delta — or as the full request/header fallback snapshot when the change is inexpressible in the delta encoding (adding exit_plan_mode is a front-of-list insertion with no delta form; removing it on exit is a pure removal and DOES delta) — 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'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 filter left, 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 — and the bash trio is hidden while an access cap cannot be honored (the access cap). Foreign additions pass 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.
The cap-derived guard at tools/pre-execute
The one execution-phase listener is scoped to the access cap; without a declared cap (or in the default mode, or for an agent-less call) every call passes through. It exists because ToolRegistry.execute() dispatches any registered tool by name — the assemble filter alone cannot stop a hallucinated call to a hidden bash tool, and neither surface may let a capped mode run an unconfinable shell.
tools/pre-execute: no exec.agent → next() // agent-less calls have no session to fold
folded mode default / no access → next()
name not in {bash, bash_output, bash_kill} → next()
no confining executor → deny // the cap cannot be honored; never run unconfined
name = bash with sandbox_permissions → deny // no widening mid-mode; plan the step instead
otherwise → next() // the clamp (below) confines execution
The guard folds the LOGGED mode only, never the pending intent — enforcement judges by the same state the request's header shipped under. It never returns { kind: 'ask' }: 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 and the registry's ask vocabulary stays free for genuine permission gating. A call to exit_plan_mode outside plan mode is rejected by the tool's own folded-mode recheck.
The access cap: clamping the bash resolution
A definition's access is the widest sandbox access shell commands run under while the mode is in force — the SANDBOX_MODES ladder from dsh-bash (read-only | workspace-write | danger-full-access); the built-in plan ships access: 'read-only'. The cap is made real on the bash seam's own resolution point: BashExecutor.resolveMode(session) computes session override ?? executor default and dispatches it through the bash/resolve-mode waterfall (declared in dsh-bash; consulted by dsh-tool-bash at both its stamping site and its escalation baseline), and dsh-mode registers the clamp listener — min(await next(), access) on the ladder, per call.
The composition is read-time over two independent log folds. The session's sandbox knob (bash/sandbox-mode events, the sandbox RFC) is never written by the mode: a knob flipped during plan stays logged but capped, exiting plan uncovers it unchanged, the two switch in any order without interference, and there is no restore step for a crash to strand. Attribution stays log-adjacent on both axes — a clamped call's width is explained by the standing mode/set, a knob change by its own bash/sandbox-mode event.
Two rules ride with a declared cap. The bash trio (bash/bash_output/bash_kill) is CONDITIONAL: both surfaces admit it only while a confining executor is mounted (ctx.get('bash')?.sandboxMode set) — an unconfinable shell cannot honor the cap, so under dsh-bash-local the trio is hidden by the filter and denied by the guard. And sandbox ESCALATION is denied at the guard: a bash call carrying sandbox_permissions in a capped mode gets a deny that points the model at putting the widened step in the plan — without this rule the clamped resolution would read as a legitimate escalation baseline and one approval prompt could pierce the cap mid-mode. A mode without access gets neither rule — its shell is the deployment's explicit uncapped choice.
exit_plan_mode
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), re-checks the folded mode as defense in depth, then conducts the review: one single-select ctx.userInteraction.ask() question — approve, or keep planning — with the free-text channel open for feedback. Approve records the switch back to default as a SILENT boundary-applied pending intent (flushed at this step's end, still in-turn) and returns a short confirmation; the plan policy — the sandbox clamp, the exit tool's visibility — therefore keeps holding for every remaining call of the SAME assistant response (they were requested under the plan-shaped header), and the next step runs unclamped, logging the pure-removal header-delta. Every other outcome — keep-planning (the user's feedback text carried verbatim), an aborted question, a missing provider — returns the corrective isError that tells the model to revise and re-present, and the mode stays plan.
Its render intent, 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 review question arrives beside this already-streamed card, so what the human reviews is exactly the logged artifact. The seam is consumed opportunistically (ctx.get('userInteraction')), so dsh-mode composes without it and degrades to the manual exit pinned in the FAQ.
Dependencies and surfaces
dsh-mode is one product package, not a capability-seam trio (Alternatives considered): it peers on cordis, dsh-session, dsh-agent, dsh-tools, dsh-system-prompt, and dsh-bash (the SANDBOX_MODES vocabulary and the bash/resolve-mode clamp listener; the executor itself is read opportunistically via ctx.get('bash'), so the composition works without one), 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; 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
input.json gains one step op, { "op": "setMode", "modeId": "plan" }, driven through the real session/set_mode RPC, and a scripted elicitationAnswers queue (FIFO, consumed by the harness client's elicitation callback — the review question's answer). The plan-mode scenario: initialize → newSession → setMode(plan) → a prompt the model answers by running a real cat through the bash tool INSIDE plan (executed under the clamped read-only sandbox on replay — Seatbelt on macOS, bwrap on Linux CI) and presenting the plan via exit_plan_mode → a scripted approve → the same turn's next step edits for real, unclamped. Because the mode is set before turn 1, the FIRST request/header snapshot is already in plan shape (the full toolset plus the exit tool, the mode section, reason initial); the approved exit logs the pure-removal request/header-delta, and the scenario pins both, plus the mode/set pair. A sibling plan-mode-reject scenario scripts the keep-planning answer with feedback text and pins the corrective result. Both need a with-key recording session; the guard's deny texts — and the sandbox-denial marker, whose recorded stderr would be the backend's dialect and replay only where it was recorded — are pinned at the unit tier.
The mechanical tail
No new cordis event is declared IN dsh-mode (mode/set rides session/event; the policy listeners attach to existing waterfalls) — the one new event, the bash/resolve-mode waterfall the access cap clamps, lives in dsh-bash where the resolution it wraps lives. Regenerated in the same change: the events catalog (bash/resolve-mode), the persistence log catalog (mode/set), the services catalog (ctx.modes, JSDoc-complete), the config catalog (ModeConfig), the tool catalog (exit_plan_mode), the producer/consumer map and doc graphs, and the module graph. Repo plumbing: a root tsconfig paths entry, the new group's README plus a packages map row (a new top-level group is the deliberate act that table names), an architecture.md capability-services row for ctx.modes (budget-checked), and the cookbook row upgrade.
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 RFC 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 recorded snapshot scenarios are landed: plan-mode (the pinned-header arc — plan-shaped initial header, a real cat under the clamp, 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. The cap guard's deny paths stay pinned at the unit tier.
FAQ
Behavioral clarifications of the chosen design; rejected designs live in Alternatives considered, accepted costs in Consequences.
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 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 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 read-only 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. Plan mode therefore enforces exactly where an enforcer exists — the sandbox cap on the shell — and restrains everything else by its section until tool definitions declare their effects (Deferred, where the removed interim allowlist is archived with its restart trigger); the interim exposure is an accepted cost (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).
How does plan mode's read-only relate to the sandbox knob's read-only? They are two independent folds over the same log with different owners: the knob (bash/sandbox-mode, the sandbox RFC) is the session's standing choice, the mode's access is a cap that rides the mode — and the cap is a clamp, never a write. resolveMode folds override ?? default and the clamp takes the ladder minimum at read time, per call, so the two switch in any order without interference: a knob flipped during plan stays logged but capped, exiting plan uncovers it unchanged, and there is no restore step for a crash to strand. The log attributes each axis to its own event — a clamped call's width to the standing mode/set, a knob change to its bash/sandbox-mode event.
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, recorded in the feature matrix now that both this stack's picker and the sandbox stack's config options are landed. 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.
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 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 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. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only hole closes where an enforcer exists: the shell is confined by the kernel-backed sandbox clamp, not by text; outside the shell the harness currently accepts guidance-only restraint, deliberately and temporarily (FAQ), rather than shipping the name-list non-fix.
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).
A capability-seam trio. Interface/implementation/consumer fits a swappable backend; a mode's variable parts are config values, not implementations. Splitting would manufacture an empty implementation package — the same "don't split preemptively" call the approval seam and todo/ made.
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.
A per-mode tool allowlist with a deny-by-default gate (the first shipped shape). Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist yet. The replacement scopes enforcement to the one real enforcer (the sandbox access cap) and parks the general dimension on effects self-declaration (Deferred). The interim consequence — outside the shell, plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately as a pre-release trade, priced in Consequences.
Prompt-only bash confinement. The Pi failure shape (Prior art) applied to the one surface that matters most: asking nicely does not stop a shell command. The access cap is kernel-backed enforcement (bash/resolve-mode clamp over the sandbox executor), never text.
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.
Mode flips as context/message via agent.inject(). Reuses an existing turn-enclosure path, but puts policy state into the model transcript — the model does not need to be told twice (the section already tells it), and a log-only fact should not occupy surface.
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 and the shipped pickers fill it with more than plan (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 narrow surface this plugin filters (the exit tool, an unhonorable-cap trio) makes filter-order races non-exploitable. Formalize only if real conflicts appear.
Exit approval through the approval seam (a { kind: 'ask' } gate decision). The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (allowed-once/rejected), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's ask vocabulary stays available to deployments that want one there.
Exit by prose or steering instead of a tool. No artifact and no approval moment — the tool's argument IS the reviewable plan, and its review question is what gives the human a structured yes/no attached to the exact transition.
Consequences
What holds now, pinned by the unit, protocol, and snapshot tiers:
- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a
mode/setis followed by the matchingrequest/headerevent (delta or fallback snapshot) on the next step with the dev invariant green throughout. - A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result.
- In the default mode the plugin is invisible: assemblies are byte-identical with and without
dsh-modeloaded, and every pre-existing snapshot golden is unchanged. - In plan mode the guidance section and the
exit_plan_modetool reach both the wire request and the logged header; entering plan logs the fallback header snapshot (front-of-list insertion), the approved exit logs the pure-removal header-delta. - Under a confining executor the bash trio stays available in plan mode with every call's sandbox resolution clamped to
read-only(thebash/resolve-modewaterfall); the session's sandbox knob is never written by the mode and re-emerges intact on exit; asandbox_permissionsescalation inside a capped mode is denied at the guard. Without a confining executor the trio is hidden and denied. - Mode definitions (section text,
accesscap) are changeable fromcordis.ymlwith no code edit; an unknown mode name fails validation loudly atset()time, an unknown definition key at load. exit_plan_mode's approve path flips the mode and lifts the plan constraints on the next step; the keep-planning path returns the correctiveisErrorcarrying the user's feedback and stays in plan mode; the ACPsession/set_moderound-trip updatescurrent_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. Outside the shell, a non-default mode restrains mutations by guidance alone until effects self-declaration lands: a model that ignores the section CAN write during plan — the review moment, the session log (every call attributable under the plan-shaped header), and the fs stack's own policies are the audit surface meanwhile; this is the deliberate pre-release trade archived in Alternatives considered, with the effects item in Deferred as its restart trigger. The mode filter prepends and hides only the exit tool and an unhonorable-cap bash trio; a listener that ALSO prepends after dsh-mode loads could re-show them — the cap guard keeps an unconfined bash non-executable either way, and a re-shown exit tool self-rejects outside plan, so the residual is cosmetic. Plan mode's shell power is exactly as real as the composition: under dsh-bash-local (or no executor) the trio is withheld in plan — an unhonorable cap must not be offered — and the guard names the three bash tools rather than deriving them from tool metadata, the same generalization gap the effects item closes. 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, recorded in the feature matrix. 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.