refactor(plan): remove generic mode abstraction

This commit is contained in:
Tianyi Cui
2026-07-22 16:57:23 +08:00
parent 92da23270d
commit f4185122dc
61 changed files with 990 additions and 1161 deletions

View File

@@ -30,4 +30,4 @@ An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC
Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design.
The default `cordis.yml` composes [`@deepseek-ai/dsh-mode`](../packages/mode/mode), [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval), and [`@deepseek-ai/dsh-permission`](../packages/ui/permission). A capable client gets a `default` / `plan` mode picker plus one independent `Permissions` select: plan adds model guidance and the reviewed `exit_plan_mode` crossing without changing enforcement, while `workspace-write` confines bash to the configured workspace and asks before a wider retry. See [acp-agent/README.md](acp-agent/README.md#plan-mode) for the plan-review and elicitation flow.
The default `cordis.yml` composes [`@deepseek-ai/dsh-plan-mode`](../packages/plan/plan-mode), [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval), and [`@deepseek-ai/dsh-permission`](../packages/ui/permission). A capable client gets a `default` / `plan` mode picker plus one independent `Permissions` select: plan adds model guidance and the reviewed `exit_plan_mode` crossing without changing enforcement, while `workspace-write` confines bash to the configured workspace and asks before a wider retry. See [acp-agent/README.md](acp-agent/README.md#plan-mode) for the plan-review and elicitation flow.

View File

@@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code
```
The leaf config loads the ACP app, DeepSeek adapter, session modes, sandboxed bash, the sandboxed filesystem stack, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds local tool-result spill storage for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode).
The leaf config loads the ACP app, DeepSeek adapter, plan mode, sandboxed bash, the sandboxed filesystem stack, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds local tool-result spill storage for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode).
## stdout is the protocol
@@ -29,13 +29,13 @@ Add to your Zed `settings.json` under `agent_servers`:
}
```
The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). The filesystem tools now ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same `workspaceRoot`.
The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). The filesystem tools ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` remain available regardless of plan state and confined to the same `workspaceRoot`.
## Plan mode
The same `demo:acp` server composes [`@deepseek-ai/dsh-mode`](../../packages/mode/mode/), so a capable client advertises `default` and `plan` in its mode picker. This composition owns the complete plan instructions in [`cordis.yml`](cordis.yml): remain in the selected mode, inspect before asking, avoid mutations, resolve discoverable repository facts, and submit a decision-complete plan through `exit_plan_mode`. Those are the instrumental behaviors shared by the local Codex and Claude Code references; product-specific plan files, phase machinery, and protocol tags stay out of the plugin contract.
The same `demo:acp` server composes [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/), so a capable client advertises `default` and `plan` in its mode picker. ACP owns those protocol ids and projects them onto the plugin's boolean plan state. This composition owns the complete plan instructions in [`cordis.yml`](cordis.yml): remain in plan mode, inspect before asking, avoid mutations, resolve discoverable repository facts, and submit a decision-complete plan through `exit_plan_mode`. Those are the instrumental behaviors shared by the local Codex and Claude Code references; product-specific plan files, phase machinery, and protocol tags stay out of the plugin contract.
Plan mode adds only that configured guidance section. Every tool, including `exit_plan_mode`, keeps the same schema in `default` and `plan`; the exit tool describes itself as plan-only and rejects if called outside plan mode. Stable native schemas and Code Mode SDK bindings avoid tool-catalog churn at the transition. `ask_user_question` carries blocking user-owned choices through ACP elicitation, while `exit_plan_mode` renders the exact logged plan for approval and returns keep-planning feedback to the model. The mode picker and permission select remain independent: switching modes never changes sandbox or approval state, and deployments that need a hard read-only planning floor configure that policy separately. The [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) owns the state and review contract.
Plan mode adds only that configured guidance section. Every tool, including `exit_plan_mode`, keeps the same schema while plan mode is inactive or active; the exit tool describes itself as plan-only and rejects if called while inactive. Stable native schemas and Code Mode SDK bindings avoid tool-catalog churn at the transition. `ask_user_question` carries blocking user-owned choices through ACP elicitation, while `exit_plan_mode` renders the exact logged plan for approval and returns keep-planning feedback to the model. The mode picker and permission select remain independent: switching plan state never changes sandbox or approval state, and deployments that need a hard read-only planning floor configure that policy separately. The [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) owns the state and review contract.
## Snapshot tests (record-once / replay-deterministic)

View File

@@ -29,8 +29,8 @@ flowchart LR
bundle_agent_core --> spine_sessions["ctx.sessions"]
bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
plugin_acp_mode["mode<br/>@deepseek-ai/dsh-mode"]
cfg --> plugin_acp_mode
plugin_acp_plan_mode["plan-mode<br/>@deepseek-ai/dsh-plan-mode"]
cfg --> plugin_acp_plan_mode
plugin_acp_tool_ask_user["tool-ask-user<br/>@deepseek-ai/dsh-tool-ask-user"]
cfg --> plugin_acp_tool_ask_user
plugin_acp_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
@@ -78,7 +78,7 @@ flowchart LR
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
| `mode` | `@deepseek-ai/dsh-mode` |
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |

View File

@@ -63,15 +63,12 @@
Verify your work by running the code or tests. Keep answers brief and factual.
# Collaboration modes are additive to the canonical ACP server. The ACP bridge
# advertises the picker, while dsh-mode owns the logged state, plan guidance,
# and reviewed exit; sandbox and approval remain independent session options.
- id: mode
name: '@deepseek-ai/dsh-mode'
# Plan mode is additive to the canonical ACP server. The ACP bridge projects
# it onto the protocol picker; sandbox and approval remain independent options.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
modes:
plan:
section: |
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.

View File

@@ -5,4 +5,4 @@
{"jsonrpc":"2.0","id":3,"result":{}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}
{"jsonrpc":"2.0","id":4,"result":{}}
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown mode \"yolo\" — available modes: default, plan"}}
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown session mode \"yolo\" — available modes: default, plan"}}

View File

@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"50138298-1385-449e-b252-146acb0571d0","createdAt":1784525384931,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-DTr6Ra","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1784525384935,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"mode/set","seq":1,"time":1784525384935,"data":{"mode":"plan"}}
{"type":"plan/mode","seq":1,"time":1784525384935,"data":{"active":true}}
{"type":"user/message","seq":2,"time":1784525384935,"data":{"content":[{"type":"text","text":"Read the file notes.txt (use the relative path notes.txt exactly, never an absolute path), then present a short plan titled '# Fix the greeting typo' via exit_plan_mode, exactly once. If the review does not approve, summarize the reviewer's feedback in plain text and end your reply - do not present the plan again."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":3,"time":1784525384935,"data":{"title":"Read the file notes.txt (use","messageSeqs":[2],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":4,"time":1784525384938,"data":{"turn":1,"step":1}}

View File

@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"5fa52100-2da3-4be1-ac1d-6c2e1fba01c6","createdAt":1784525374896,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1784525374902,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"mode/set","seq":1,"time":1784525374902,"data":{"mode":"plan"}}
{"type":"plan/mode","seq":1,"time":1784525374902,"data":{"active":true}}
{"type":"user/message","seq":2,"time":1784525374902,"data":{"content":[{"type":"text","text":"The typo is on line 2 of notes.txt itself; the workspace contains no other file. Inspect it with the bash tool: run exactly `cat notes.txt` (relative path, no other command). Then present a one-step plan titled '# Fix the greeting typo' via exit_plan_mode: the single step is editing line 2 of notes.txt to say hello world. Do not edit anything before the review approves. After it approves, apply exactly that one edit to notes.txt with the edit tool and stop — no other commands, do not look for any other file."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":3,"time":1784525374902,"data":{"title":"The typo is on line","messageSeqs":[2],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":4,"time":1784525374904,"data":{"turn":1,"step":1}}
@@ -228,7 +228,7 @@
{"type":"tool/call","seq":226,"time":1784525378322,"data":{"turn":1,"step":2,"callId":"call_00_6RLxuiGtAFswvfLnWdt63143","name":"exit_plan_mode","arguments":"{\"plan\": \"# Fix the greeting typo\\n\\n## Single step\\n1. **Edit line 2 of `notes.txt`** — Replace the content of line 2 (`- the greeting message still says \\\"helo wrld\\\"`) with `hello world`.\"}"}}
{"type":"tool/result","seq":227,"time":1784525378329,"data":{"turn":1,"step":2,"callId":"call_00_6RLxuiGtAFswvfLnWdt63143","content":[{"type":"text","text":"Plan approved — plan mode exited; carry out the plan starting with your next step."}],"isError":false},"sourceEventSeqs":[226],"surfaceOp":"append"}
{"type":"step/end","seq":228,"time":1784525378329,"data":{"turn":1,"step":2}}
{"type":"mode/set","seq":229,"time":1784525378329,"data":{"mode":"default"}}
{"type":"plan/mode","seq":229,"time":1784525378329,"data":{"active":false}}
{"type":"step/start","seq":230,"time":1784525378330,"data":{"turn":1,"step":3}}
{"type":"request/header","seq":231,"time":1784553020470,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
{"type":"assistant/chunk","seq":232,"time":1784525378723,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -29,7 +29,7 @@
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-lsp": "workspace:*",
"@deepseek-ai/dsh-lsp-local": "workspace:*",
"@deepseek-ai/dsh-mode": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
"@deepseek-ai/dsh-permission": "workspace:*",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",

View File

@@ -1,6 +1,6 @@
# tui-agent
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, session modes (`/plan` enters plan mode, which has a reviewed exit), timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo).
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo).
## Run it

View File

@@ -47,8 +47,8 @@ flowchart LR
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_tui_tool_todo
plugin_tui_mode["mode<br/>@deepseek-ai/dsh-mode"]
cfg --> plugin_tui_mode
plugin_tui_plan_mode["plan-mode<br/>@deepseek-ai/dsh-plan-mode"]
cfg --> plugin_tui_plan_mode
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_tui_fs_local
plugin_tui_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
@@ -83,7 +83,7 @@ flowchart LR
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `mode` | `@deepseek-ai/dsh-mode` |
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |

View File

@@ -88,15 +88,12 @@
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Session modes (the shipped `plan` definition): the TUI's command registry
# gains the plugin-registered /plan [message] command, and the exit review
# rides the TUI's user-interaction provider.
- id: mode
name: '@deepseek-ai/dsh-mode'
# Plan mode gives the TUI a plugin-owned /plan [message] command; the exit
# review rides the TUI's user-interaction provider.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
modes:
plan:
section: |
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.

View File

@@ -15,12 +15,10 @@
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: mode
name: '@deepseek-ai/dsh-mode'
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
modes:
plan:
section: 'Stay in plan mode for this scripted TUI test.'
section: 'Stay in plan mode for this scripted TUI test.'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'

View File

@@ -15,7 +15,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import ModesService, { PLAN_MODE } from '@deepseek-ai/dsh-mode'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -56,7 +56,7 @@ const SCENARIOS: Scenario[] = [
name: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
expectedEventCounts: { 'mode/set': 1 },
expectedEventCounts: { 'plan/mode': 1 },
enterPlanMode: true,
recorded: true,
},
@@ -209,7 +209,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.enterPlanMode === true) {
await ctx.plugin(ModesService, { modes: { plan: { section: 'Snapshot plan mode instructions.' } } })
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
}
if (scenario.composition === 'code' || scenario.composition === 'advanced') {
await ctx.plugin(WorkerCodeRuntime, {})
@@ -298,13 +298,13 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
}
if (scenario.enterPlanMode === true) {
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
const modeSet = events.find(event => event.type === 'mode/set')
expect(ctx.planMode.get(agent)).toEqual({ active: true })
const planMode = events.find(event => event.type === 'plan/mode')
const firstHeader = events.find(event => event.type === 'request/header')
if (modeSet === undefined || firstHeader === undefined) {
throw new Error('plan-mode command snapshot needs a mode/set before its first request/header')
if (planMode === undefined || firstHeader === undefined) {
throw new Error('plan-mode command snapshot needs plan/mode before its first request/header')
}
expect(modeSet.seq).toBeLessThan(firstHeader.seq)
expect(planMode.seq).toBeLessThan(firstHeader.seq)
expect(firstHeader.data.header.system).toContain('Snapshot plan mode instructions.')
const firstMessage = events.find(event => event.type === 'user/message')
expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }])