DSH / Atlas
2026-07-07implementedfeaturearchived 2026-07-26

Plan mode — a logged per-agent session mode

plan mode——记录到日志的逐 agent 会话模式

Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log. The extension seams already supplied the surrounding pieces: [

English

Problem

Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log.

The extension seams already supplied the surrounding pieces: system-prompt/assemble shapes guidance per step and the shipped request is logged in request/header* events (reconstructability); ctx.userInteraction carries the approval question and corrective feedback (ask-user precedent); SessionEventMap carries durable per-agent facts (the todo/write precedent). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes.

Decision

The deliverable is plan mode. It ships as the first session mode — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, @deepseek-ai/dsh-mode at packages/mode/mode/, owns the event vocabulary, a thin ctx.modes service, and every listener; the loop does not change. plan is the only required 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 mode guidance — no section, filtering, or gate. Loading dsh-mode still contributes one stable exit_plan_mode schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries.

A mode's whole surface is soft: a mode:policy prompt section renders the active definition's guidance, while exit_plan_mode remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is plan. A transition therefore changes only the system-prompt portion of the attributable request/header on the next step, keeping reconstructability green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects (Deferred); a mode's restraint is its section's guidance plus the exit review.

The model leaves plan mode through the exit_plan_mode tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, 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 through the ACP mode picker or /plan [message] in a terminal front door, and from the next step every request ships the configured plan guidance section. When the optional message is present, that same command submits it into the affected step. The exit_plan_mode schema was already present in default and remains byte-identical.

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.

When ready, the model calls exit_plan_mode with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface.

On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is 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 deployment must provide the complete plan section; the package embeds no model instructions. Additional modes use the same config map:

- 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.

A definition is exactly { section } — there is deliberately no per-mode tool list and no enforcement field (FAQ). Definition names use the lowercase slash-command subset /^[a-z][a-z0-9_-]*$/u; default is reserved (the absence of policy) and rejected as a key. An invalid name or unknown definition key — a tools list or an access cap included — fails validation at load; an unknown mode name fails loudly at set() time.

In the terminal

Terminal front doors get one entry command per configured definition through the plugin-owned command registry (@deepseek-ai/dsh-commands): dsh-mode registers /plan [message] for the required definition and, for example, /review [message] when review is configured. Each command records its named switch; a non-empty optional message is trimmed and passed to agent.steer(), which places it in a running agent's next step or delegates to send() for a new idle turn. The command name and result stay out of model history, while that explicit message is logged as an ordinary user message under the selected mode. The synthetic default entry contributes no command. 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

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 reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review detail because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the 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. 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.

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 Agent Note made for request-header facts (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 }                    // prompt text — a mode's whole vocabulary
interface ModeConfig { modes: Record<string, ModeDefinition> }  // plan is required and owns its complete prompt
resolveConfig(config): ResolvedModes                            // explicit resolve (the dsh-bash template), fail-loud:
                                                                // missing plan, 'default', blank sections, and unknown keys rejected

The one-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) and folds the append-only session log directly; mode/set is not a surface node, so compaction cannot shadow 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 — drops a no-op (target equals pending, else current), and otherwise records { mode, narrate } in a WeakMap pending-intent slot. 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, and agent/turn-continuation fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended agent/request-error wrapper delegates through the composed policy and asynchronous backoff, then flushes only a retry decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit session/event observers are observe-only), so every step runs 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.

The soft layer: a computed section and a stable exit schema

The registered prompt section reads the calling agent's mode from AssembleContext.agent and resolves to the active definition's guidance or ''. The loop renders per step and logs a complete request/header whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request (Prior art's compaction-survival hack) is unnecessary prompt churn.

The guidance contribution is { name: 'mode:policy', order: 50, text: context => … }: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. exit_plan_mode is registered once through ctx.tools and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without dsh-mode lacks that one binding. There is NO tools/pre-execute listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam.

exit_plan_mode

defineTool has one required plan: string argument. Native execution records it in the ordinary tool/call; Code Mode records the outer run_code source before execution and appends the normalized nested arguments in tool/code-dispatch after the dispatch settles. execute rejects an agent-less call (the todo_write precedent), rejects any folded mode other than plan, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select ctx.userInteraction.ask() review whose detail is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one Approve selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to default and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective isError and leaves the mode in 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. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction detail independently carries the same plan on every provider. 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, 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 the per-definition entry commands). 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 each definition's command 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; 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. The plan-mode scenario enters plan before turn 1, runs a real cat under the independently configured sandbox, presents a plan through exit_plan_mode, receives scripted approval, then edits on the next step. The first request/header contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. plan-mode-reject pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier.

The mechanical tail

No new cordis event is declared (mode/set rides session/event; the listeners attach to existing waterfalls), so the events catalog is untouched. Regenerated in the same change: 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 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 ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas.

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 pre-assembly boundary: agent/prompt-submit covers the first step, agent/turn-continuation covers a normal successor, and the post-composed agent/request-error retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. 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? A folded mode name the current config no longer defines behaves as the default mode without a notice, so the session neither gains a substitute restriction nor becomes unusable. 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, 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).

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).

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) — 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.

Why aren't sandbox mode, approval policy, or the model themselves modes? They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the automation-only protocol decision. 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 exposes Plan beside Default as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control.

The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract.

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 shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement (FAQ).

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. The general dimension is parked on effects self-declaration (Deferred); the consequence — plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately, priced in Consequences.

An access sandbox cap on the mode (the second shipped shape). Also removed before release. ModeDefinition.access clamped the bash seam's per-call sandbox resolution to a mode-declared ceiling (a bash/resolve-mode waterfall + ladder-min listener, with guards withholding bash under an unconfinable executor and denying escalation mid-mode). The state stayed orthogonal — the clamp never wrote the sandbox knob — but the AXES did not: entering plan changed what the sandbox enforced, fusing the collaboration stance with an enforcement level and contradicting the Codex-shaped separation the review converged on (Plan/Default presets never touch sandbox or approval settings). One user-visible symptom of the fusion: flipping the sandbox option to workspace-write while planning silently did nothing. The cap, the waterfall, and the mode→bash dependency edge were removed together; a deployment gets kernel-enforced read-only planning by pairing the mode with the independent sandbox-mode option, and a mode-triggered PRESET (a mode definition bundling suggested knob values, applied as ordinary knob switches) can return later without re-fusing the axes.

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: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement.

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, snapshot, and real-API 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/set is followed by a matching complete request/header on the next changed step.
  • 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 default mode the plugin contributes no mode section but does contribute the stable exit_plan_mode schema; a deployment without dsh-mode lacks that binding.
  • Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes.
  • Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning.
  • Mode definitions are changeable from cordis.yml with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at set().
  • exit_plan_mode is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective isError; each human-facing surface's user-interaction provider carries the review.
  • 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). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. A mode restrains by guidance alone: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in Alternatives considered and Deferred. Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither.

中文

问题

此次变更之前,harness 无法持久地让某个 agent(智能体)采用独特的工作姿态。Plan mode 要求 agent 在规划指引下探索和设计,产出可供评审的产物,跨过明确的审批边界,并在恢复与 fork 后还原该状态,同时不能让模型可见请求偏离会话日志。

既有扩展 seam 已经提供了周边机制:system-prompt/assemble 为每个步骤塑造指引,已发送的请求则记录在 request/header* 事件中(参见可重建性);ctx.userInteraction 承载审批问题与纠正反馈(参见 ask-user 先例);SessionEventMap 承载逐 agent 的持久事实(参见 todo/write 先例)。缺少的是将这些 seam 连在一起的具名会话状态,同时仍让独立的沙箱轴与审批轴负责执行约束。

决策

交付项是 plan mode。它作为首个会话模式发布,即一个具名、记录到日志且逐 agent 生效的协作状态:模式定义是由部署配置、供模型查看的指引;对某个 agent 生效的模式则是从其日志折叠出的会话状态。模式构成一条轴,强制约束旋钮——沙箱模式与审批策略——构成其他轴;它们从不互相读写,这与 Codex 将 Plan/Default 协作预设同沙箱及审批设置分开的做法一致。新的产品包(package)@deepseek-ai/dsh-mode 位于 packages/mode/mode/,拥有事件词汇、精简的 ctx.modes 服务和全部监听器;循环无需改动。plan 是唯一的必需定义;采用模式形状的词汇,是为了以后增加第二种模式时无需重命名持久事件类型,而不是因为当前还会发布其他模式。

该状态是 SessionEventMap 的一个成员:mode/set 是只记录日志、不进入 surface 的事件,携带具有整值替换语义的 { mode: string };另有纯函数 foldMode(events) 返回生效模式,即最后一个 mode/set,没有该事件时则返回默认模式。由于日志是事实通道,恢复、fork 和压缩无需额外机制即可还原模式,UI 则从 session/event 读取模式切换。默认模式表示不存在模式指引,即没有段落、过滤或门禁。加载 dsh-mode 后,每种模式仍会贡献同一个稳定的 exit_plan_mode schema;这项固定成本避免了模式边界处的工具目录抖动。

模式的所有外显行为都是软约束:mode:policy 提示词段落渲染当前定义的指引,而 exit_plan_mode 在每种模式下都留在已注册的工具目录中,仅当折叠模式不是 plan 时才在执行阶段拒绝。因此,转换只会在下一步骤改变可归因 request/header 中的系统提示词部分,从而在不改变 Native schema 或 Code Mode SDK 的情况下继续满足可重建性。模式有意不强制执行任何约束:没有执行门禁,不过滤工具,也不触及沙箱或审批旋钮。若用户希望规划期间存在硬性的只读下限,可以在模式选择器旁切换沙箱模式选项;二者先后顺序任意,任何一条轴都不会扰动另一条轴。同样也不存在逐模式的工具允许/拒绝清单;模式允许哪些工具属于副作用问题,在工具定义能够声明自身副作用前暂缓处理(见延期工作)。模式只依靠其段落指引与退出评审来约束行为。

模型通过 exit_plan_mode 工具离开 plan mode。其唯一参数是 plan 文本,因此可以从日志重建 plan;该工具自行通过用户交互 seam 完成评审:问题的辅助详情携带确切 plan,并提供选项与自由文本通道,而不是只有一项裸权限。审批通过后,记录到日志的模式切回默认模式;拒绝则成为携带用户逐字反馈的纠正错误,让模型能沿明确方向继续规划。用户可从任意接口通过 ctx.modes.set() 切换模式;切换会在下一个轮次边界应用(会话事件都封闭在轮次内),且只有模型可见状态确实变化时才向模型讲述一次。

高层 API

一次端到端的 plan-mode 会话

用户通过 ACP 模式选择器或终端入口中的 /plan [message] 将会话切换到 plan mode;从下一步骤开始,每个请求都携带已配置的 plan 指引段落。如果给出可选消息,同一命令还会把它提交到受影响的步骤中。exit_plan_mode schema 在默认模式下已经存在,并会保持逐字节不变。

模型进行探索与设计;段落中的指引会让它把变更推迟到 plan 中。沙箱与审批旋钮保持用户设置的值不变;希望规划期间由内核强制只读的部署方(或用户),可以把 plan mode 与独立的沙箱模式选项配合使用。

准备就绪后,模型调用 exit_plan_mode,并把 plan markdown 作为参数;评审问题将这段确切 markdown 作为辅助详情,用户可以批准,也可以要求继续规划并自由填写反馈。Native 调用还会渲染 plan 卡片;Code Mode 嵌套分发没有 Native 卡片,因此评审详情是共用的呈现接口。

批准后,工具把记录到日志的模式切回默认模式:下一步骤会移除 plan 段落,但保留同一个工具目录(变化后的 header 已记录到日志),此后的执行跟踪本就由 todo_write 负责。要求继续规划时,模型会收到携带用户反馈文本的纠正错误,随后修改并再次呈现。

部署配置

模式定义是经过校验的插件 Config;依照仓库约定,它可以通过 cordis.yml 修改,无需编辑代码。部署必须提供完整的 plan 段落;该包不内置任何模型指令。其他模式使用同一份配置 map:

- 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.

定义的精确形状是 { section };其中有意不提供逐模式工具清单或强制约束字段(见常见问题)。定义名称使用小写斜杠命令子集 /^[a-z][a-z0-9_-]*$/udefault 是保留项(表示没有策略),不能用作键。名称无效或存在未知定义键——包括 tools 清单或 access 上限——会在加载时校验失败;未知模式名称则会在调用 set() 时大声失败。

在终端中

终端入口通过插件自有的命令注册表(@deepseek-ai/dsh-commands),为每个已配置定义获得一条进入命令:dsh-mode 为必需定义注册 /plan [message],例如还会注册 /review [message](当配置 review 时)。每条命令都记录其具名切换;非空的可选消息会去除首尾空白并传给 agent.steer(),后者会把消息放入运行中 agent 的下一步骤,或委托给 send() 以开启新的空闲轮次。命令名称与结果不会进入模型历史;这条显式消息则会作为所选模式下的普通用户消息记录到日志。合成的 default 条目不贡献命令。退出评审无需新机制即可直接在终端中提示:它是普通的用户交互问题,因此会进入组合后的用户交互提供方提示队列,与 ask_user_question 使用的队列相同。

通过 ACP

模式选择器是该包的对外接口:session/newsession/load 会通告 availableModescurrentModeId,其值来自 ctx.modes(通过 ctx.get 机会式消费,沿用 tool-bash 模式);session/set_mode 调用 set() 并乐观通知 current_mode_update(待生效模式就是用户的选择,记录到日志的 mode/set 会在边界处跟进);session/event 监听器则会在每次已记录切换不同于最近一次已发送值时再次通知。退出工具复用用户交互 ACP 提供方的 elicitation 流程;其 ACP 映射会携带评审 detail,因为 Code Mode 嵌套分发没有 Native plan 卡片,而 Native 调用还可以额外流式传输该卡片。沙箱模式、审批策略和模型等单项环境旋钮不是模式,应归入 session/set_config_option(见常见问题)。

面向 agent 创建方

ctx.modes 是完整的程序化接口:list() 返回已配置定义和供选择器使用的合成 default 条目;get(agent) 返回折叠模式与可能存在的待生效意图;set(agent, mode) 则根据 list() 的词汇校验名称,并记录将在边界应用的意图。default 始终是有效目标,因此退出模式与进入模式使用同一次调用。创建时没有模式选项;调用方在首个轮次前通过 set() 选择模式,随后以相同方式刷写。系统也不提供可订阅的实时 agent/* 镜像:UI 依照事件领域语义读取 mode/set,该事件来自 session/event

详细设计

词汇

'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

载荷不携带原因/溯源字段:工具驱动的切换在日志中紧邻其 tool/call,用户切换则位于轮次边界,因此原因就在日志相邻位置。这与可重建性 Agent Note针对请求头事实所作的「叙述字段可以派生」决策相同(进行中的 env/state 事件之所以携带 source,正是因为其漂移变体在日志相邻位置没有原因;二者形成对照,并不冲突)。模式名称是配置声明的词汇,不是不透明的跨边界 id,因此仍使用裸字符串(不使用 Branded<B>)。

配置与解析步骤

interface ModeDefinition { section: string }                    // prompt text — a mode's whole vocabulary
interface ModeConfig { modes: Record<string, ModeDefinition> }  // plan is required and owns its complete prompt
resolveConfig(config): ResolvedModes                            // explicit resolve (the dsh-bash template), fail-loud:
                                                                // missing plan, 'default', blank sections, and unknown keys rejected

单字段形状是有意采用的最简设计,并非最终词汇:逐工具策略维度会以工具定义中的副作用元数据形式回归(见延期工作),在此处读取,而不是由每种模式重新声明;该维度到来时,配置形状不应需要迁移。

折叠、服务与刷写

foldMode(events) 是纯函数(导出供重建方与测试使用),直接折叠仅追加的会话日志;mode/set 不是 surface 节点,因此压缩无法遮蔽它。set(agent, mode) 根据 list() 的词汇校验名称,即已配置定义加上保留的 default;后者不能用作配置键,却始终可以作为 set() 目标。目标与待生效模式相同(没有待生效模式时则与当前模式相同)时,该方法丢弃无操作;其余情况会把 { mode, narrate } 记录到 WeakMap 的待生效意图槽中。它不能立即追加,因为每个会话事件都封闭在轮次内,而空闲 agent 没有打开的轮次。

循环拦截 seam 上经过故障隔离的监听器(参见防御模式:策略插件不得阻塞提示词或轮次)会把待生效意图刷写为一条 mode/set 追加:agent/prompt-submit 在刚打开的轮次中、首次组装前触发;agent/turn-continuation 则在普通步骤关闭后、后续步骤开始前触发。自动请求恢复会绕过 continuation,因此,前置的 agent/request-error 包装器会先委托给组合后的策略和异步退避,只在 waterfall 返回循环前刷写 retry 决策;effect 作用域的生命周期守卫会抑制在插件资源释放后才恢复的已捕获包装器。三条路径都位于工具执行与日志发布之外(提交后的 session/event 观察器只负责观察),因此每个步骤都在其组装所折叠出的模式下运行。刷写模式与最后一个 request/header 处的折叠结果不同时,刷写会在同一帧中追加一条合并后的 context/message 通知(「用户已将此会话切换到 plan mode。」);面向用户的叙述情形列在常见问题中。

软层:计算得出的段落与稳定的退出 schema

已注册的提示词段落从 AssembleContext.agent 读取调用 agent 的模式,并解析为当前定义的指引或 ''。循环逐步骤渲染,并在渲染后的 header 发生变化时记录完整的 request/header,因此进入或离开模式均可归因。该段落在每种模式内保持静态,plan 本身则以消息和工具参数留在对话中;无需为了跨压缩保留状态,而在每个请求中重新注入独立的 plan 状态(既有方案采用的办法),徒增提示词抖动。

指引贡献为 { name: 'mode:policy', order: 50, text: context => … }:排在人设(0)之后、工具指引(100–199)之前,并在默认模式或没有 agent 的组装中为空。exit_plan_mode 只通过 ctx.tools 注册一次且从不过滤,因此模式切换期间 Native schema 与 Code Mode 生成的 SDK 保持逐字节相同;未部署 dsh-mode 的环境则没有这项绑定。系统不注册 tools/pre-execute 监听器:模式不设置任何门禁,退出工具自身的折叠模式检查会拒绝 plan 之外的调用。退出评审是一个带选项和反馈的问题,不是权限,因此位于工具通过用户交互 seam 执行的过程内。

exit_plan_mode

defineTool 有一个必填的 plan: string 参数。Native 执行会把它记录在普通 tool/call 中;Code Mode 在执行前记录外层 run_code 源码,并在分发结算后把规范化的嵌套参数追加到 tool/code-dispatchexecute 会拒绝没有 agent 的调用(沿用 todo_write 先例)和折叠模式不是 plan 的调用,并在询问评审人前拒绝空 plan 或不含标题的 plan;随后,它通过 ctx.userInteraction.ask() 发起一次单选评审,其 detail 是确切 plan,并开放自由文本反馈,供用户批准或要求继续规划。只有恰好选择一个 Approve 才表示同意,其他任何形状都按失败关闭处理。批准会记录一项将在边界生效且不叙述的意图,用于切换到 default,并返回简短确认。部署指引要求模型把这次调用作为回复中唯一且最后一次工具调用;如果模型违反该规则,运行时仍会让该批次剩余部分保留 plan 指引,下一步骤才记录变化后的 header,其中移除指引而工具 schema 保持不变。所有未获批准的结果都会返回纠正性的 isError,并让模式留在 plan

渲染意图在设计之初就已确定:presentCallgeneric 卡片,以 plan 的首个标题命名、以 plan markdown 作为内容,另配一张 generic 结果卡片。Native 入口会在问题之前显示该卡片;Code Mode 嵌套分发不会产生 Native 调用卡片事件,因此用户交互 detail 会在每个提供方上独立携带同一份 plan。系统机会式消费该 seam(ctx.get('userInteraction')),所以 dsh-mode 在没有它时仍可组合,并降级为常见问题中确定的手动退出方式。

依赖与接口

dsh-mode 是一个产品包,而不是由三个包组成的能力 seam(见考虑过的替代方案):它对等依赖 cordisdsh-sessiondsh-agentdsh-toolsdsh-system-prompt,注入 ['tools', 'systemPrompt'],并在执行时机会式读取 ctx.userInteraction(指向 dsh-user-interaction 的仅类型对等依赖边);其仅有的 UI 侧边也是可选的仅类型对等依赖(逐定义进入命令使用 dsh-commands)。除 ctx.modes 调用接口外,所有内容都通过监听器参与,因此移除该包会平稳移除模式,而不会破坏消费方。终端入口无需模式专用代码:组合命令注册表后,dsh-mode 会自行注册每个定义的命令(指向 dsh-commands 的可选仅类型对等依赖边),退出评审则使用组合后的用户交互提供方提示队列。高层 API 已确定 ACP 协议映射;在包关系上,桥接层对 dsh-mode 采用仅类型对等依赖边并机会式读取服务,所以不含该插件的桥接层行为与当前完全相同。

已记录场景与 harness 操作

input.json 新增一种步骤操作 { "op": "setMode", "modeId": "plan" },通过真实的 session/set_mode RPC 驱动,并配有脚本化的 elicitationAnswers 队列。plan-mode 场景在第 1 个轮次前进入 plan,在独立配置的沙箱下运行真实的 cat,通过 exit_plan_mode 呈现 plan,接收脚本化审批,然后在下一步骤编辑。首个 request/header 包含完整、稳定的工具集和已配置模式段落;批准后变化的 header 会保留逐字节相同的工具 schema,只移除该段落。plan-mode-reject 固定纠正性的自由文本反馈和未变化的 plan 状态。两份记录都在 Seatbelt 或 bwrap 下回放宿主命令;后端特有的沙箱拒绝仍留在 bash 工具单元层。

机械收尾

系统不声明新的 Cordis 事件(mode/set 通过 session/event 传递,监听器附着到现有 waterfall),因此事件目录不变。同一变更重新生成以下内容:持久化日志目录(mode/set)、服务目录(ctx.modes,JSDoc 完整)、配置目录(ModeConfig)、工具目录(exit_plan_mode)、生产方/消费方 map 与文档图,以及模块图。仓库接线包括:根 tsconfig 的 paths 条目、新包组 README 和包索引中的一行(新增顶层包组正是该表所命名的有意操作)、architecture.md 中经过预算检查的 ctx.modes 能力服务行,以及实操手册对应行的升级。

延期工作

以下各项都需要独立决策:通过转发的创建时模式选项实现 subagent 模式继承(由于没有消费方而移除,将随首个消费方回归);plan 之外的预设模式(只读、接受编辑);若待生效意图丢失被证明是真实问题,则引入空闲记录原语;以及最重要的在工具定义上自行声明副作用,即逐工具的只读/变更分类(MCP ToolAnnotations 词汇——readOnlyHintdestructiveHint——是自然模板,其中对不可信提示的警告意味着还需区分信任层级)。通用的逐模式工具策略正在等待这一项:本 Agent Note 最初发布过临时的逐模式名称允许清单,并在发布前移除;手工维护的清单错误地表达了副作用问题,必须跟踪部署所组合的每个工具,且会随工具增加而无声腐化。因此,按模式限制的工具可用性(以及逐工具 ask 策略)会作为已声明副作用的消费方回归,而首项消费需求就是其重启触发条件。

ACP 自动化组合不挂载 plan mode 或问题工具。面向人类的组合拥有 plan 选择与评审;聚焦的 plan-mode 测试和交互接口快照会固定其已记录状态、指引、评审和稳定工具 schema。

常见问题

以下内容澄清选定设计的行为;遭否决的设计见考虑过的替代方案,已接受的代价见后果

用户切换模式后何时生效? 在下一个组装前边界生效:agent/prompt-submit 覆盖首个步骤,agent/turn-continuation 覆盖普通后续步骤,组合策略之后的 agent/request-error 重试决策覆盖自动恢复。因此,在请求或重试退避进行期间选择的模式会塑造下一次模型请求。这就是既有方案中每项产品都采用的「应用于后续请求」语义。

何时向模型讲述模式变化? 仅当模型可见状态确实变化时:刷写会把刚刷写的模式与最后一个 request/header 处的折叠结果进行比较,并合并讲述一次。净变化为零的切换序列(先进入 plan,再在边界前切回)不会产生叙述;工具驱动的退出只通过自身工具结果叙述;首个轮次前设置的模式也不叙述,因为该段落本身就是状态说明。该原则来自进行中 env-state 提案的边界叙述:如果提示词表层悄然切换,transcript(文本记录)仍会依据 header 已不再具备的状态进行论述。

恢复时,配置已不再定义折叠出的模式会怎样? 当前配置不再定义的折叠模式名称会在不通知的情况下表现为默认模式,因此会话既不会获得替代约束,也不会变得不可用。set() 的大声校验只覆盖写入路径;恢复后的日志以当时找到的配置为准。

如果部署没有组合用户交互提供方,会怎样? Plan mode 仍然安全,但只能手动退出:ctx.userInteraction.ask() 会抛出 NO_PROVIDER(seam 不存在时甚至无法解析到该服务),工具返回纠正性的 isError,退出方式降级为由用户切换模式,绝不会在未经评审时退出。模式段落会要求模型通过 exit_plan_mode 呈现 plan,并在失败时改用普通文本询问用户,因此模型会继续呈现,而不会停滞。

为何没有逐模式工具允许清单? 因为「哪些工具在规划模式下安全」是每个工具自身的属性(即副作用),不是模式的属性。逐模式名称清单会在错误的归属位置重新声明该事实,必须枚举部署所组合的每个工具(包括 MCP 服务器),且会随工具到来而无声腐化。在工具定义声明其副作用前(见延期工作,其中归档了被移除的临时允许清单及其重启触发条件),模式只通过自身段落和退出评审约束行为;由此产生的暴露面属于已接受代价(见后果)。

subagent 是否继承父级模式? fork 子级可以直接继承,因为父级的 mode/set 位于种子前缀中。spawn 子级从默认模式开始;创建时模式选项与 subagent 提供方的自动转发一并延期(见延期工作)。

plan mode 与沙箱只读模式有何关系? 二者是互不接触的独立轴:模式是协作姿态(mode/set 折叠),沙箱模式是强制约束旋钮(bash/sandbox-mode 折叠,参见沙箱 Agent Note)。Plan mode 既不读取也不限制沙箱模式,与 Codex 将 Plan/Default 预设同沙箱及审批设置分开的做法完全一致。希望规划期间由内核强制只读的用户需要同时设置两者:以任意顺序切换模式选择器与沙箱模式选项;每次切换只改变自身折叠结果,因此二者互不干扰,也不存在可能崩溃的还原步骤。日志会把每条轴归因到各自事件:协作姿态对应 mode/set,隔离约束对应 bash/sandbox-mode

为何沙箱模式、审批策略或模型本身不属于模式? 它们是独立于协作状态的单项环境旋钮。已退役的 ACP 映射记录在仅面向自动化的协议决策中。未来模式定义可以捆绑环境事实(在挂载处通过 ctx.envState 应用),让 Codex 风格的预设仍是一种模式;但把审批策略融合进模式概念本身的方案已在考虑过的替代方案中遭否决。

既有方案

对已发布 plan mode(Claude Code、Cursor、Copilot、OpenCode、Gemini CLI、Cline、Windsurf、Codex)的调研表明,各产品都包含同样五个部分:低权限工具策略、plan 产物、审批时刻、执行状态切换,以及问题所依赖的持久状态。

只要产品公开模式接口,该接口就一定是清单,绝不是布尔值:Claude Code 的选择器提供 plan,旁边是 acceptEdits(另有自动进入 plan 的模式);Codex 则把 PlanDefault 并列公开为协作模式预设,同时让审批和沙箱设置保持独立。ACP 传输层不公开这项面向人类的控制。

由部署拥有的示例提示词借鉴工具性行为,而非产品特有机制。它借鉴 Codex 的以下做法:即使收到祈使式实现语言也留在 plan mode;提问前先探索;区分仓库事实与由用户决定的选择;让 plan 完整覆盖 API、数据流、失败、测试和假设,从而足以作出决策。它还借鉴 Claude Code 的以下做法:禁止变更与提交;优先沿用现有模式;只针对需求或方案选择提问;通过退出工具完成规划,而不在普通文本中请求审批。它有意省略 Codex 协议标签,以及 Claude 的 plan 文件或分阶段 subagent 机制,因为这些属于各自运行时,而非本插件契约。

把模式留给约定的生态展示了应避免的失败形态。Pi 风格的模式扩展会争抢一个后写覆盖的全局活跃工具清单,只靠提示词文本强制「只读」(模型幻觉调用一个仍已注册的工具时,该调用会实际执行),并在每个请求中重新注入 plan 状态以跨过压缩。这里通过逐 agent 的折叠状态,以及压缩无法遮蔽的只记录日志、非 surface 事件,从结构上消除了有争议的全局清单和重复注入补丁。相较之下,仅靠提示词的形态被有意保留:Codex 的 Plan 正是如此实现,这也是模式轴可以与强制约束轴自由组合的原因。需要硬性下限的部署会把模式与独立的沙箱旋钮配对,而不是让模式携带自身强制约束(见常见问题)。

考虑过的替代方案

以权限模式作为核心概念(Claude Code 的形态)。 用一个 permissionMode 融合审批策略和工具策略。本设计中,它们是归不同所有者负责的两条轴:审批 seam 拥有「谁回答这个问题」,模式拥有「模型获得什么接口」。ACP 将二者建模为相关但有区别的概念(模式以后可以选择审批策略;届时是模式定义增加字段,而不是合并两者)。

由三个包组成的能力 seam。 接口/实现/消费方适合可替换后端;模式的可变部分是配置值,而不是实现。拆分会制造一个空实现包,与审批 seam 及 todo/ 作出的「不要过早拆分」决策相同。

由循环拥有模式状态。 依据既有规则(用插件,而不修改循环)予以否决:该功能所需的每个钩子——组装、执行前处理、轮次边界、会话事件——都已经是有文档记录的 seam,修改循环只会增加耦合,别无收益。

带默认拒绝门禁的逐模式工具允许清单(首个发布形态)。 已在发布前移除。手工维护的名称清单会逐模式重新声明一项逐工具事实(即其副作用):它必须枚举部署所组合的每个工具,包括 MCP 服务器和未来注册项,并会随工具增加而无声腐化(新增只读工具时,在有人编辑每种模式前都会被阻止;编写负担落在了解模式的人身上,而不是了解工具的人身上)。它还过度承诺:该清单看似安全边界,但 shell 之外的任何能力其实都不存在这种真实边界。通用维度已停放到副作用自声明(见延期工作);由此产生的后果——plan mode 只提供指引,也就是该门禁一度弥补的 Pi 缺口——是有意接受的,并计入后果

模式上的 access 沙箱上限(第二个发布形态)。 同样已在发布前移除。ModeDefinition.access 会把 bash seam 的逐调用沙箱解析限制在模式声明的上限内(一个 bash/resolve-mode waterfall 加上取阶梯最小值的监听器;守卫会在执行器无法施加约束时隐藏 bash,并在模式中途拒绝提升权限)。状态仍保持正交,因为上限从不写入沙箱旋钮;但两条轴并不正交:进入 plan 会改变沙箱实际强制执行的内容,把协作姿态与强制约束级别融合起来,违背评审最终达成的 Codex 形态分离方式(Plan/Default 预设从不触及沙箱或审批设置)。这种融合有一项用户可见症状:规划期间把沙箱选项切换为 workspace-write 不会产生任何效果。上限、waterfall 和 mode→bash 依赖边随后一并移除;部署可以把模式与独立沙箱模式选项配合使用,从而在规划期间由内核强制只读。以后也可以重新引入模式触发的预设(模式定义捆绑建议的旋钮值,并作为普通旋钮切换加以应用),而无需再次融合两条轴。

仅存在于运行时的模式(只在 UI 或桥接层本地存在,不记录日志)。 恢复与 fork 会悄然丢失模式,模式引起的 header 增量在日志中也没有可归因原因。记录到日志的状态让模式无需额外机制即可审计和还原。

把模式切换作为 context/message 并通过 agent.inject() 写入。 这样可以复用现有的轮次封闭路径,却会把策略状态放入模型 transcript;模型无需被告知两次(段落已经告诉它),而只记录日志的事实不应占用 surface。

plan 文件存储(.plans/ 目录)。 这会为日志已经以可回放方式承载的内容创建第二个持久归属位置;需要文件的部署可以添加一个写入文件的工具。同一事实只应有一个归属位置。

用布尔值 planMode 取代具名模式。 对仓库已经跟踪的接口而言过于狭窄:ACP 通告的是模式清单,已发布的选择器也不只填入 plan(见既有方案);以后再泛化会重命名持久事件词汇。字符串形状的机制现在不产生额外成本;只有 plan 作为定义发布。

工具策略栈服务(对 Pi 批评的补救方案)。 现在就为工具策略建立专用组合服务为时过早:本实现不执行按模式限制的工具过滤,未来的副作用策略可以通过现有带守卫的执行 seam 组合。只有已声明的工具副作用产生具体组合需求后,才应正式建立该服务。

通过审批 seam 执行退出审批(一个 { kind: 'ask' } 门禁决策)。 最初草案提出该方案;当时审批 seam 是唯一正在落地的询问机制,所以显得自然,但它把评审放进了权限的位置。该 seam 的结果词汇有意封闭且仅供单次使用(allowed-oncerejected),因此拒绝无法携带反馈,批准也永远无法增加选项(例如「批准并接受编辑」)。退出时刻是一个问题,而非权限;用户交互 seam 为其提供选项与自由文本通道,拒绝反馈也会逐字传给模型。审批 seam 仍适合真正的权限门禁(沙箱提升权限),注册表的 ask 词汇也继续供希望在该处设置询问的部署使用。

使用普通文本或 steering(中途引导)退出,而不使用工具。 这样既没有产物,也没有审批时刻。工具参数本身就是可供评审的 plan,而评审问题会把结构化的是/否选择附着到确切转换上并交给人类。

后果

以下保证现在已经由单元、协议、快照和真实 API 测试层固定:

  • 生效模式是会话日志的纯函数:恢复与 fork 无需额外机制即可还原它;下一变化步骤中,一条 mode/set 后会出现匹配的完整 request/header
  • 用户驱动的切换会在下一边界恰好叙述一次,净变化为零的切换序列不会产生叙述;工具驱动的退出只通过自身工具结果叙述。
  • 默认模式下,插件不贡献模式段落,但会贡献稳定的 exit_plan_mode schema;未部署 dsh-mode 的环境没有这项绑定。
  • 默认、plan 与自定义模式之间转换时,Native 工具 schema 与 Code Mode SDK 保持逐字节相同;只有已配置的指引段落发生变化。
  • Plan mode 不改变强制约束轴上的任何内容:工具集、沙箱模式、权限提升和审批策略在 plan 与默认模式下表现完全相同;部署通过把模式与独立的沙箱/审批旋钮配合使用来强化规划。
  • 模式定义可通过 cordis.yml 修改,无需编辑代码;其中必须提供完整 plan 指令。缺失 plan 配置、定义畸形和未知键会在加载时失败,未知模式名称会在 set() 时失败。
  • exit_plan_mode 始终通告,在 plan 之外会拒绝;批准后只移除 plan 指引,并通过纠正性的 isError 携带继续规划反馈;每个面向人类的接口都由其用户交互提供方承载评审。
  • 随功能落地一并交付的文档收尾包括:README、重新生成的目录(持久化日志、配置、Cordis 服务、工具)、包索引与架构行,以及实操手册中的对应行。

已接受的代价如下:在 agent 空闲时设置的待生效用户切换,如果进程在下一轮次前退出,就会丢失(UI 会重新应用;若实践中出现问题,空闲记录原语就是逃生口)。模式转换会改变顺序 50 处的系统提示词,因此从该处开始的缓存路径也会变化,但工具 schema 与 Code Mode SDK 不再抖动。模式只依靠指引约束行为:忽略该段落的模型可以在 plan 期间执行变更;评审时刻、会话日志以及独立的沙箱、审批和文件系统策略共同构成约束边界。强化规划意味着设置这些旋钮,而不是扩大模式职责;被移除的强制约束形态及其副作用声明重启触发条件仍记录在考虑过的替代方案延期工作中。面向人类的接口拥有 plan 选择器与评审交互;ACP 自动化传输层两者都不承载。