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
@@ -2,6 +2,8 @@
Status: implemented
> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed.
## 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.
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-plan-specific-collaboration-state.md: 8a7caf9b1150cb6d3ea2c8ed52e42751f30c773c
2026-07-22-plan-specific-collaboration-state.zh.md: c4d2528cc06a74ce8c152199bc2503daff315dbf
@@ -0,0 +1,47 @@
# Agent Note: Collapse named session modes into plan mode
Status: implemented
English | [中文](2026-07-22-plan-specific-collaboration-state.zh.md)
## Problem
The first plan-mode implementation introduced a generic named-mode registry even though the product shipped only `plan`. `ModeConfig.modes`, definition-name validation, `ctx.modes.list()`, retired-definition fallback, and a synthetic `review` mode in tests existed only to support hypothetical future collaboration modes. The production-specific behavior—plan guidance, `/plan`, and `exit_plan_mode`—still lived in the same package, so the generic API did not isolate a reusable mechanism from plan policy.
The word “mode” also spans unrelated domains. Sandbox mode is an enforcing policy owned by `ctx.sandboxPolicy` and logged as `sandbox/mode`; plan mode is a collaboration stance that contributes guidance and a reviewed exit. Treating both as instances of one named-mode abstraction would obscure their independent ownership. ACP's protocol happens to expose a generic mode picker, but that is an adapter vocabulary rather than evidence that the harness needs a generic mode domain.
## Decision
Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning.
Configuration is exactly `{ section: string }`. The package registers the fixed `plan:policy` section, `/plan [message]`, and `exit_plan_mode` itself. Bare `/plan` selects the state; a non-empty argument selects it first and then sends the trimmed text through `agent.steer()`, making the text an ordinary logged user message in the affected step. The exit tool remains registered while plan mode is inactive so the request tool catalog stays stable.
ACP keeps its protocol-level `default` and `plan` ids. The bridge maps those two ids to the boolean service, advertises only that fixed pair, rejects every other id at the adapter boundary, and maps committed `plan/mode` events back to `current_mode_update`. The protocol remains generic without forcing genericity into the product domain.
Sandbox mode and approval policy remain separate enforcement axes. Plan mode neither reads nor writes them, and the simplification introduces no shared base type, registry, or preset abstraction across those concepts.
## Deleted surface
- The arbitrary definition map, mode-name regular expression, reserved-name rules, and per-definition command loop.
- `ModeDefinition`, the resolved definition map, `ctx.modes.list()`, string-valued get/set state, and unknown or retired mode handling.
- Test-only `review` mode cases and claims that additional modes can be added through configuration.
- Generic `mode/set` and `mode:policy` names; the plan package now owns `plan/mode` and `plan:policy`.
## Alternatives considered
**Keep a private generic registry and expose only plan today.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases.
**Fold sandbox mode into the same service.** Rejected because collaboration guidance and execution confinement have different owners, lifecycle semantics, and consumers. Their shared English noun is not a domain relationship.
**Let ACP own plan state.** Rejected because TUI, resume, fork, prompt assembly, and the exit tool need the same logged fact independently of ACP. ACP owns only the wire projection.
## Verification
- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service.
- Command tests cover bare `/plan`, `/plan <message>`, absence of `/mode` and `/review`, and effect-scoped removal.
- ACP tests cover fixed advertisement, both ids, unknown-id rejection, optimistic updates, committed exits, and load replay.
- The keyless TUI scenario enters through `/plan <message>` and proves `plan/mode` precedes the first request header and that the message is logged under plan guidance.
## Consequences
The implementation has one vocabulary for one shipped feature. Adding another collaboration stance is now an explicit design decision instead of a config entry, while ACP clients continue to see their standard mode picker. The migration intentionally rejects old `mode/set` logs and old `modes.plan.section` configuration under the repository's pre-release format policy.
@@ -0,0 +1,47 @@
# Agent Note: 将具名会话模式收敛为 plan mode
Status: implemented
[English](2026-07-22-plan-specific-collaboration-state.md) | 中文
## 问题
产品只交付了 `plan`,首个 plan mode 实现却引入了通用的具名模式注册表。`ModeConfig.modes`、定义名称校验、`ctx.modes.list()`、已退役定义的回退逻辑,以及测试中合成的 `review` 模式,都只为支持假想中的未来协作模式而存在。plan 引导、`/plan``exit_plan_mode` 这些生产专用行为仍位于同一个包(package)内,因此通用 API 并未将可复用机制与 plan 策略隔离开来。
「mode」一词还横跨互不相关的领域。沙箱模式是由 `ctx.sandboxPolicy` 拥有、以 `sandbox/mode` 记录日志的强制执行策略;plan mode 则是一种协作方式,会贡献引导内容和经评审的退出路径。若把两者都视为同一个具名模式抽象的实例,就会掩盖二者各自独立的归属关系。ACPAgent Client Protocol)协议恰好暴露了通用模式选择器,但这只是适配器词汇,并不能证明 harness 需要通用模式领域。
## 决策
Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/``@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false``ctx.planMode.get(agent)` 返回 `{ active, pending? }``set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。
配置严格为 `{ section: string }`。该包自行注册固定的 `plan:policy` 段、`/plan [message]``exit_plan_mode`。不带参数的 `/plan` 选择该状态;非空参数则先选择该状态,再通过 `agent.steer()` 发送去除首尾空白后的文本,使该文本在受影响的步骤中成为一条记录到日志的普通用户消息。即使 plan mode 未激活,退出工具仍保持注册,以确保请求工具目录稳定。
ACP 保留协议层的 `default``plan` id。桥接层把这两个 id 映射到布尔服务,只公布这组固定选项,在适配器边界拒绝其他所有 id,并把已提交的 `plan/mode` 事件映射回 `current_mode_update`。协议仍保持通用性,但不会迫使产品领域也采用通用抽象。
沙箱模式与审批策略仍是彼此独立的强制约束轴。Plan mode 既不读取也不写入二者;此次简化也没有为这些概念引入共享基类型、注册表或预设抽象。
## 删除的接口
- 任意定义映射、模式名正则表达式、保留名称规则以及逐定义命令循环。
- `ModeDefinition`、解析后的定义映射、`ctx.modes.list()`、字符串值的 get/set 状态,以及未知或已退役模式处理。
- 仅用于测试的 `review` 模式用例,以及可通过配置添加其他模式的表述。
- 通用的 `mode/set``mode:policy` 名称;plan 包拥有 `plan/mode``plan:policy`
## 考虑过的替代方案
**保留私有的通用注册表,目前只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。
**将沙箱模式折叠进同一服务。** 不予采纳,因为协作引导与执行约束有不同的归属方、生命周期语义和消费方。二者的英文名称都含「mode」,不代表存在领域关系。
**让 ACP 拥有 plan 状态。** 不予采纳,因为 TUI、恢复、fork、提示词组装和退出工具都需要在 ACP 之外独立使用同一项已记录事实。ACP 只拥有协议投影。
## 验证
- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。
- 命令测试覆盖不带参数的 `/plan``/plan <message>`、不存在 `/mode``/review`,以及随 effect 作用域移除。
- ACP 测试覆盖固定模式列表公布、两个 id、未知 id 拒绝、乐观更新、已提交退出和加载回放。
- 无密钥 TUI 场景通过 `/plan <message>` 进入,证明 `plan/mode` 先于首个请求头,且消息在 plan 引导下记录到日志。
## 后果
该实现只用一套词汇描述一项已交付功能。若要添加另一种协作方式,必须显式作出设计决策,而不能只增加配置项;ACP 客户端仍可看到标准模式选择器。根据仓库的预发布格式策略,本次迁移有意拒绝旧的 `mode/set` 日志与 `modes.plan.section` 配置。
+1 -1
View File
@@ -24,7 +24,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool
workflow/ workflow seam + worker-thread engine + the workflow tool
todo/ the todo_write tool
mode/ session modes: plan mode as logged per-agent collaboration state
plan/ plan mode as logged per-agent collaboration state
guard/ loop-hygiene plugins
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
architecture.md: 51d5d59ab5ece4d3c7e10a4a2fefdca0e05630e3
architecture.zh.md: 72eecc7cb5500abd7d89a4212b29819c5a780d32
architecture.md: d7555368b583203cee664da3615c4fb4fb680c30
architecture.zh.md: bafe59dee0559d5969c7400bdae85be0d012b3b5
+1 -1
View File
@@ -37,7 +37,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
| `ctx.modes` | [`mode/`](../packages/mode/README.md) | logged session modes |
| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
+1 -1
View File
@@ -37,7 +37,7 @@
| `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 |
| `ctx.compact``ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction);可选的无模型结果裁剪 |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 |
| `ctx.modes` | [`mode/`](../packages/mode/README.md) | 落日志的会话模式 |
| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制工具 |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
+5 -5
View File
@@ -54,8 +54,8 @@ flowchart LR
pkg_user_interaction["user-interaction"]
svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"]
pkg_tui["tui"]
pkg_mode["mode"]
svc_modes["ctx.modes<br/>Session-mode state"]
pkg_plan_mode["plan-mode"]
svc_planMode["ctx.planMode<br/>Plan collaboration state"]
pkg_commands["commands"]
svc_commands["ctx.commands<br/>Human command registry"]
pkg_skill["skill"]
@@ -137,8 +137,8 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
pkg_mode --> svc_modes
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
pkg_sandbox --> svc_sandbox
pkg_sandbox_local --> svc_sandbox
pkg_sandbox_policy --> svc_sandboxPolicy
@@ -194,8 +194,8 @@ flowchart LR
svc_invariants --> pkg_session
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
svc_modes --> pkg_acp
svc_permission --> pkg_acp
svc_planMode --> pkg_acp
svc_sandbox --> pkg_bash_sandbox
svc_sandboxPolicy --> pkg_bash_sandbox
svc_sandboxPolicy --> pkg_fs_sandbox
@@ -256,7 +256,7 @@ flowchart LR
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged per-agent mode/set state, flushes user flips at turn boundaries, renders deployment-owned guidance, and keeps the plan-exit schema stable across modes. |
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
+15 -31
View File
@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:263`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:275`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
@@ -754,36 +754,6 @@ export interface StreamableHttpConfig {
Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-mode`
Requires: `tools` · `systemPrompt`
```ts config-catalog
/**
* Plugin config: mode definitions by name. The deployment must define
* {@link PLAN_MODE}, including its complete model instructions;
* {@link DEFAULT_MODE} is rejected as a key and definition names must be valid
* slash-command names ({@link resolveConfig} throws at load).
*/
export interface ModeConfig {
/** Mode definitions by name; `plan` is required and owns its full prompt text. */
modes: Record<string, ModeDefinition>
}
/**
* One mode's deployment-configured policy: the guidance section the model
* sees. Deliberately nothing else — enforcement knobs (sandbox mode, approval
* policy) are separate axes a mode never touches, and a tool allow/deny list
* is an effects question parked until tool definitions declare their effects.
*/
export interface ModeDefinition {
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
section: string
}
```
Source: [`packages/mode/mode/src/index.ts:99`](../packages/mode/mode/src/index.ts)
## `@deepseek-ai/dsh-permission`
Requires: `bash` · `approval`
@@ -816,6 +786,20 @@ Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMod
Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts)
## `@deepseek-ai/dsh-plan-mode`
Requires: `tools` · `systemPrompt`
```ts config-catalog
/** Deployment-owned plan guidance. */
export interface PlanModeConfig {
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
section: string
}
```
Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
extension-cookbook.md: 7cf857c69218d345fb021e9173655975d3a60fa2
extension-cookbook.zh.md: ad978dc5e6b7737fc4b057b89d4c4a676bbb7349
extension-cookbook.md: 8873cac21960e2e2efe0e8c6c5868c3a8e7ee75c
extension-cookbook.zh.md: f34e9f2fa707be69b13ac408cc1ede1a86310fae
+1 -1
View File
@@ -113,7 +113,7 @@ Every product feature maps to a listener on a documented extension seam — the
| Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded |
| Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial |
| Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions |
| Plan mode | Shipped: [`@deepseek-ai/dsh-mode`](../../packages/mode/mode/README.md) — the logged `mode/set` state, the `mode:policy` guidance section, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes |
| Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]`, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes |
| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model |
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |
+1 -1
View File
@@ -113,7 +113,7 @@ export function apply(ctx: Context) {
| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 |
| 子进程沙箱(landlock / sandbox-exec | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` |
| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 |
| Plan mode | 已交付:[`@deepseek-ai/dsh-mode`](../../packages/mode/mode/README.md) — 落日志的 `mode/set` 状态、`mode:policy` 引导段,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 |
| Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段`/plan [message]`,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 |
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
+27 -39
View File
@@ -689,45 +689,6 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../cor
Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts)
## `ctx.modes` — `ModesService`
`ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the pending-intent flush, the boundary narration, the `mode:policy` section, and the stable exit tool. UIs read mode flips off `session/event`; there is no live mirror.
```ts cordis-catalog
/**
* The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the
* configured definitions — the list a mode picker advertises.
*
* @returns Mode names, `default` first.
*/
list(): string[]
/**
* The agent's mode state: the folded mode in force (a folded name the config
* no longer defines reads as {@link DEFAULT_MODE}) plus the pending
* user-selected intent awaiting its boundary flush, when one exists.
*
* @param agent The agent to read.
* @returns The current (effective) mode and the pending intent, if any.
*/
get(agent: Agent): { current: string; pending?: string }
/**
* Select the agent's mode. Validates the name against {@link list} (loud on
* unknown; `default` is always a valid target), drops a no-op (target equals
* the pending intent, else the current fold), and otherwise records a
* pending intent flushed as a `mode/set` at the next turn boundary.
*
* @param agent The agent to switch.
* @param mode The target mode name.
*/
set(agent: Agent, mode: string): void
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/mode/mode/src/index.ts:217`](../../packages/mode/mode/src/index.ts)
## `ctx.permission` — `PermissionService`
Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error.
@@ -772,6 +733,33 @@ Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-d
Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts)
## `ctx.planMode` — `PlanModeService`
`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror.
```ts cordis-catalog
/**
* Read the logged plan state and any selected state awaiting a boundary.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean }
/**
* Select whether plan mode should be active from the next turn boundary.
* Repeated selection of the current or already-pending state is a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
*/
set(agent: Agent, active: boolean): void
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end.
+4 -4
View File
@@ -14,15 +14,15 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`mode`](../packages/mode/mode) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
@@ -57,7 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`mode`](../packages/mode/mode), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/status` | - | [`agent`](../packages/core/agent) |
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
+12 -12
View File
@@ -86,8 +86,8 @@ flowchart TD
subgraph group_todo["packages/todo"]
pkg_tool_todo["tool-todo"]
end
subgraph group_mode["packages/mode"]
pkg_mode["mode"]
subgraph group_plan["packages/plan"]
pkg_plan_mode["plan-mode"]
end
subgraph group_cordis["packages/cordis"]
pkg_tool_cordis["tool-cordis"]
@@ -465,13 +465,13 @@ flowchart TD
pkg_tool_todo --> pkg_invariants
pkg_tool_todo --> pkg_session
pkg_tool_todo --> pkg_tools
pkg_mode --> pkg_agent
pkg_mode --> pkg_commands
pkg_mode --> pkg_invariants
pkg_mode --> pkg_session
pkg_mode --> pkg_system_prompt
pkg_mode --> pkg_tools
pkg_mode --> pkg_user_interaction
pkg_plan_mode --> pkg_agent
pkg_plan_mode --> pkg_commands
pkg_plan_mode --> pkg_invariants
pkg_plan_mode --> pkg_session
pkg_plan_mode --> pkg_system_prompt
pkg_plan_mode --> pkg_tools
pkg_plan_mode --> pkg_user_interaction
pkg_tool_cordis --> pkg_invariants
pkg_tool_cordis --> pkg_scope
pkg_tool_cordis --> pkg_tools
@@ -555,8 +555,8 @@ flowchart TD
pkg_acp --> pkg_invariants
pkg_acp --> pkg_llm
pkg_acp --> pkg_llm_retry
pkg_acp --> pkg_mode
pkg_acp --> pkg_permission
pkg_acp --> pkg_plan_mode
pkg_acp --> pkg_sandbox
pkg_acp --> pkg_session
pkg_acp --> pkg_session_persistence
@@ -746,7 +746,7 @@ flowchart TD
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`mode`](../packages/mode/mode) | `mode` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -761,7 +761,7 @@ flowchart TD
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`mode`](../packages/mode/mode), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+15 -15
View File
@@ -312,21 +312,6 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-
Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts)
### `mode/*`
#### `mode/set` — log-only
```ts persistence-catalog
/**
* The session mode in force from this point on: log-only, non-surface,
* whole-value replace — the last `mode/set` in the log wins (see
* {@link foldMode}). A log with none folds to {@link DEFAULT_MODE}.
*/
'mode/set': { mode: string }
```
Source: [`packages/mode/mode/src/index.ts:52`](../packages/mode/mode/src/index.ts)
### `permission/*`
#### `permission/preset` — log-only
@@ -343,6 +328,21 @@ Source: [`packages/mode/mode/src/index.ts:52`](../packages/mode/mode/src/index.t
Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src/index.ts)
### `plan/*`
#### `plan/mode` — log-only
```ts persistence-catalog
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
* inactive through {@link foldPlanMode}.
*/
'plan/mode': { active: boolean }
```
Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/src/index.ts)
### `prompt/*`
#### `prompt/blocked` — log-only
+4 -4
View File
@@ -17,7 +17,7 @@ This table connects model-visible tool names to the plugin package and service s
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `mode/set back to default on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary. |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
@@ -127,7 +127,7 @@ Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code
Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.
## `@deepseek-ai/dsh-mode`
## `@deepseek-ai/dsh-plan-mode`
### `exit_plan_mode`
@@ -148,9 +148,9 @@ Use only in plan mode. Present your plan for the user's review and, on approval,
}
```
Source: [`packages/mode/mode/src/index.ts`](../packages/mode/mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts`](../packages/plan/plan-mode/src/index.ts)
exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary.
exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary.
## `@deepseek-ai/dsh-tool-bash`
+1 -1
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.
+4 -4
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)
+3 -3
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` |
+5 -8
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.
@@ -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"}}
@@ -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}}
@@ -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"}}}
+1 -1
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:*",
+1 -1
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
+3 -3
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` |
+5 -8
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.
+3 -5
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'
+8 -8
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] }])
+1 -1
View File
@@ -25,7 +25,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`mode/`](mode/README.md) | Session modes: plan mode with a reviewed exit | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
+14 -18
View File
@@ -350,24 +350,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'modes',
summary: '`ctx.modes`: the session-mode service.',
methods: [
{
signature: 'list(): string[]',
jsDoc: '/**\n * The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the\n * configured definitions — the list a mode picker advertises.\n *\n * @returns Mode names, `default` first.\n */',
},
{
signature: 'get(agent: Agent): { current: string; pending?: string }',
jsDoc: '/**\n * The agent\'s mode state: the folded mode in force (a folded name the config\n * no longer defines reads as {@link DEFAULT_MODE}) plus the pending\n * user-selected intent awaiting its boundary flush, when one exists.\n *\n * @param agent The agent to read.\n * @returns The current (effective) mode and the pending intent, if any.\n */',
},
{
signature: 'set(agent: Agent, mode: string): void',
jsDoc: '/**\n * Select the agent\'s mode. Validates the name against {@link list} (loud on\n * unknown; `default` is always a valid target), drops a no-op (target equals\n * the pending intent, else the current fold), and otherwise records a\n * pending intent flushed as a `mode/set` at the next turn boundary.\n *\n * @param agent The agent to switch.\n * @param mode The target mode name.\n */',
},
],
},
{
key: 'permission',
summary: 'Owns the deployment\'s permission presets and their write path.',
@@ -390,6 +372,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'planMode',
summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
methods: [
{
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
},
{
signature: 'set(agent: Agent, active: boolean): void',
jsDoc: '/**\n * Select whether plan mode should be active from the next turn boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */',
},
],
},
{
key: 'sandbox',
summary: 'Abstract process-sandbox service.',
-9
View File
@@ -1,9 +0,0 @@
# mode/ — session-mode policy family
Session modes: named, logged, per-agent collaboration states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable part is a config value (the section text), not a swappable implementation.
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` remains registered in every mode to keep the request tool catalog stable. UIs read flips off `session/event`; the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker, and a composed [command registry](../ui/commands) gains one entry command per configured definition (`/plan [message]` for the required definition, with an optional next-step message). Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).
-121
View File
@@ -1,121 +0,0 @@
# @deepseek-ai/dsh-mode
Session modes are named, logged, per-agent collaboration states. **Plan mode** is the required first definition: the agent explores and designs under deployment-owned instructions, presents a reviewable plan, and crosses back through an explicit review. Modes are independent from enforcement knobs such as sandbox mode and approval policy.
## The mode state is a session event
`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the last logged mode or `default`. Resume, fork, and compaction therefore restore the mode from the log, and UIs observe flips through `session/event`.
The `default` mode means no mode guidance. Loading this plugin still contributes one stable `exit_plan_mode` tool schema in every mode; that fixed schema is the cost that avoids changing the tool catalog at a mode boundary.
## What a mode carries
The plugin registers one `mode:policy` prompt section at order 50. It renders the active definition's deployment-configured `section` text and renders empty in `default`, for an agent-less assembly, or when a logged definition no longer exists.
A mode does not gate execution, filter tools, or change sandbox or approval settings. A deployment that wants a hard read-only floor while planning combines plan mode with the independent sandbox and approval controls. The config vocabulary is exactly `{ section }`; unknown keys fail at load.
## `ctx.modes`
`list()` returns `default` followed by the configured definitions. `get(agent)` returns the folded mode, treating a removed definition as `default`, plus any pending intent. `set(agent, mode)` validates against that vocabulary and records a pending intent. The service flushes the intent on `agent/prompt-submit` before the first assembly, `agent/turn-continuation` before a normal successor step, or after a composed `agent/request-error` decision authorizes a retry. Each append is turn-enclosed and precedes the affected prompt assembly, including an automatic recovery step after asynchronous backoff. A changed user selection adds one coalesced `context/message` notice when the last logged request header described a different mode; a net-zero selection sequence adds nothing.
There is no creation-time mode option: a UI (or a plugin) selects through `set()` before the first turn, and a fork child needs no mechanism at all — the parent's `mode/set` is inside the seeded prefix.
## Per-mode slash commands
When a command registry (`@deepseek-ai/dsh-commands`) is composed, each configured definition contributes its own entry command to interactive front doors. The required definition supplies `/plan [message]`; a further `review` definition supplies `/review [message]`. Each command records its named switch through `set()`; when the optional message is non-empty, the handler trims it and passes it to `agent.steer()` so a running agent receives it in its next step and an idle agent starts a new turn. `default` is the absence of a definition and contributes no command. Without a commands service the child never mounts and nothing else changes.
Definition names must match `/^[a-z][a-z0-9_-]*$/u`, the shared mode/command subset; config fails at load before a definition can become selectable but undispatchable.
## `exit_plan_mode`
[`exit_plan_mode`](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is registered in every mode so native tool schemas and Code Mode's generated SDK remain byte-identical across a mode switch. Its description says it is plan-only, and execution rechecks the folded mode and rejects outside `plan`.
In plan mode, the required `plan` argument makes the review artifact durable: native dispatch records it in `tool/call`; Code Mode records the outer `run_code` source before execution and the extracted arguments in `tool/code-dispatch` when the nested dispatch settles. The review request also carries the exact plan as supporting detail, so ACP and TUI show what is being approved even when Code Mode has no nested native call card. The tool asks the user to Approve or Keep planning through `ctx.userInteraction`, with optional free-text rejection feedback. Approval schedules a silent switch to `default` at the step boundary; every non-approval outcome returns a corrective `isError` and keeps plan mode. Native presentation additionally renders the markdown as a generic plan card.
## Config
The deployment must provide the complete plan-mode instructions in Cordis config; the package has no embedded plan prompt. See the [ACP example](../../../examples/acp-agent/cordis.yml) for the maintained production-shaped instructions.
```yaml
- id: mode
name: '@deepseek-ai/dsh-mode'
config:
modes:
plan:
section: |
You are in plan mode. Explore first, make no changes, and present a decision-complete plan through exit_plan_mode.
```
`resolveConfig` requires `modes.plan.section`, rejects `default` as a definition key, rejects invalid command-shaped names, blank sections, and unknown definition keys, and preserves any further named modes. `set()` rejects an unknown mode name.
## Model Experience
### Mode guidance section
#### What the model sees
In `default`, no `mode:policy` text appears. In a configured mode, that definition's exact `section` text appears after persona and before tool guidance. The package does not own a stable prompt literal; the [example Cordis config](../../../examples/acp-agent/cordis.yml) owns the plan instructions used by the shipped composition.
#### Token effect
`default` adds no section tokens. Plan mode adds the configured section on each request; the text is static until config or mode changes.
#### KV Cache effect
Within one mode, the section is stable. Entering or leaving a non-default mode changes the system prompt at order 50, so bytes from that section onward need a new cache path; the stable prefix before it can still be reused where the provider supports prefix caching. No tool-schema or Code Mode SDK churn accompanies the transition.
### Mode transition notices
#### What the model sees
A user-driven change whose previous request header described another mode appends either `The user switched this session to <mode> mode.` or `The user switched this session back to the default mode.` A logged mode removed from config reads as default without a notice. Initial selection before the first header, net-zero selections, and the tool-driven exit add no notice.
#### Token effect
Each qualifying transition adds one short conversation message once. The dynamic mode name is the only data-dependent part.
#### KV Cache effect
The notice itself is append-only conversation growth. A real mode transition also changes the earlier order-50 section, so that section remains the limiting cache boundary.
### Per-mode command message
#### What the model sees
The command name and result remain in the direct command plane. A non-empty optional suffix is trimmed and submitted as one ordinary user text block through `agent.steer()` after the mode selection, so the resulting step sees the selected mode.
#### Token effect
The command itself adds no tokens. An optional message has the same history and token cost as submitting that text separately.
#### KV Cache effect
The optional message is append-only conversation growth. Entering the mode still changes the order-50 system-prompt section for the affected step.
### Exit tool schema and review exchange
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-mode) is present in every mode. Outside plan mode, a call returns `Error: exit_plan_mode is only available in plan mode`. In plan mode, an empty or heading-less argument returns `Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading` before review. A valid call carries the complete plan both as the tool argument and as review detail; exactly one `Approve` selection returns `Plan approved — plan mode exited; carry out the plan starting with your next step.`, while every other answer returns `Error: The user chose to keep planning; revise the plan and present it again.` or `Error: The user chose to keep planning; their feedback: <feedback>`. An unavailable review channel returns its fail-closed error and keeps the mode unchanged.
##### Stable literal
```markdown
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
```
#### Token effect
The stable cost depends on ToolRegistry mode: `native` adds the tool schema, `code` adds the generated SDK binding inside the `run_code` surface instead of a native schema, and `both` adds both representations. The plan markdown is paid once as tool-call arguments and remains in context. Each rejection adds its feedback result, and the next revision adds another complete plan tool call.
#### KV Cache effect
The tool schema and generated SDK binding are byte-identical in `default`, `plan`, and custom modes, so a mode change adds no tool-catalog diff. The earlier order-50 section change still moves the cache path as described above; schema stability avoids a second source of request-shape churn and keeps subsequent requests within the new mode on one catalog shape. Loading or unloading the plugin itself changes that catalog. Review arguments and results extend the conversation normally.
## Known Limitations and Deferred Work
- A mode restrains by guidance only; pair it with independent enforcement knobs when a hard boundary is required. The [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) records the rejected enforcement shapes and the effects-metadata restart trigger.
- A pending flip selected while idle is lost if the process exits before the next boundary; the UI must reapply it.
- Forked children inherit the logged mode, while spawned children start in `default`; there is no creation-time mode option.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md).
-474
View File
@@ -1,474 +0,0 @@
/**
* Session modes: named, logged, per-agent COLLABORATION states, with **plan
* mode** as the first shipped definition. A mode is a guidance section the
* model sees while it is in force plus, for plan, the user-reviewed
* `exit_plan_mode` crossing — deliberately nothing more. Modes are one axis
* and enforcement knobs (the sandbox mode, the approval policy) are others;
* they never read or write each other, exactly as Codex separates its
* Plan/Default collaboration presets from its sandbox and approval settings.
* There is likewise NO per-mode tool allow/deny list: which tools a mode
* admits is an effects question, parked until tool definitions can declare
* their effects (the plan-mode Agent Note's deferred item). The mode IN FORCE for an
* agent is session state, folded from its log (`mode/set`, last one wins), so
* resume and fork restore it for free.
*
* The default mode is the absence of mode guidance. The `exit_plan_mode` tool
* remains registered in every mode so request tool schemas never change at a
* mode boundary; its execute path rejects calls outside plan mode.
*
* User flips go through {@link ModesService.set}: every session event is
* turn-enclosed and an idle agent has no open turn, so `set()` records a
* pending intent and the service flushes it on the loop's interception seams:
* `agent/prompt-submit` before the first assembly, `agent/turn-continuation`
* before a normal successor step, and the post-composed
* `agent/request-error` retry decision before a recovery step. These seams are
* outside tool execution and log publication (post-commit `session/event`
* observers cannot append). A flush that changes what the last logged request
* header told the model appends one coalesced `context/message` notice in the
* same frame.
*
* Agent Note: .agents/notes/implemented/feature/2026-07-07-plan-mode.md
*
* @module @deepseek-ai/dsh-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the per-mode command child
// below; the child mounts only when a commands service is composed.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session mode in force from this point on: log-only, non-surface,
* whole-value replace — the last `mode/set` in the log wins (see
* {@link foldMode}). A log with none folds to {@link DEFAULT_MODE}.
*/
'mode/set': { mode: string }
}
}
declare module 'cordis' {
interface Context {
modes: ModesService
}
}
/**
* The mode a log with no `mode/set` folds to: the absence of policy. Reserved —
* {@link resolveConfig} rejects it as a definition key, and {@link ModesService.set}
* always accepts it as a target (a picker's exit-to-default is a valid write).
*/
export const DEFAULT_MODE = 'default'
/** The required plan definition's name. */
export const PLAN_MODE = 'plan'
// Every definition contributes a same-named slash command when the optional
// command registry is composed, so mode names use that stable common subset.
const MODE_NAME = /^[a-z][a-z0-9_-]*$/u
/**
* The model-facing exit tool's name. It stays registered in every mode so the
* request tool catalog is stable; execution outside {@link PLAN_MODE} rejects.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/**
* One mode's deployment-configured policy: the guidance section the model
* sees. Deliberately nothing else — enforcement knobs (sandbox mode, approval
* policy) are separate axes a mode never touches, and a tool allow/deny list
* is an effects question parked until tool definitions declare their effects.
*/
export interface ModeDefinition {
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
section: string
}
/**
* Plugin config: mode definitions by name. The deployment must define
* {@link PLAN_MODE}, including its complete model instructions;
* {@link DEFAULT_MODE} is rejected as a key and definition names must be valid
* slash-command names ({@link resolveConfig} throws at load).
*/
export interface ModeConfig {
/** Mode definitions by name; `plan` is required and owns its full prompt text. */
modes: Record<string, ModeDefinition>
}
/** Validated deployment-owned mode definitions, including `plan`. */
export interface ResolvedModes {
/** Definitions by mode name; never contains {@link DEFAULT_MODE}. */
definitions: ReadonlyMap<string, ModeDefinition>
}
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ 'The user may approve (carry out the plan from your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate the deployment-owned mode definitions (explicit resolve step — the
* `dsh-bash` request/spec template). Fail-loud: a missing {@link PLAN_MODE}, a
* {@link DEFAULT_MODE} key, or a malformed definition throws at load.
*
* @param config Raw plugin config.
* @returns The validated definitions, including deployment-configured `plan`.
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
// Cordis can invoke the constructor with omitted runtime config even though
// the public TypeScript contract requires `modes`; keep that invalid shape
// inside validation so it gets the actionable missing-plan error below.
const modes = (config as Partial<ModeConfig>).modes ?? {}
for (const [name, definition] of Object.entries(modes)) {
if (name === DEFAULT_MODE) {
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
}
// The same shape the package invariant enforces on `mode/set`: accepting
// an empty or untrimmed KEY here would advertise a name whose selection
// the invariant then rejects, desynchronizing the picker forever.
if (name.trim() === '' || name.trim() !== name) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must be non-empty and trimmed`)
}
if (!MODE_NAME.test(name)) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must match ${String(MODE_NAME)} for its slash command`)
}
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
if (definition.section.trim() === '') {
throw new Error(`ModeConfig: mode "${name}" needs a non-empty \`section\``)
}
// Unknown keys fail loud rather than silently shaping nothing — the
// definition vocabulary is exactly { section }: a tool allow/deny list
// and enforcement knobs are deliberately NOT part of it (module doc).
const unknown = Object.keys(definition).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`ModeConfig: mode "${name}" has unknown key(s) ${unknown.join(', ')} — a definition is { section }`)
}
definitions.set(name, { section: definition.section })
}
if (!definitions.has(PLAN_MODE)) {
throw new Error(`ModeConfig: mode "${PLAN_MODE}" is required; put its model instructions in modes.${PLAN_MODE}.section`)
}
return { definitions }
}
/**
* The mode in force after the first `end` events: the last `mode/set` wins,
* a prefix with none folds to {@link DEFAULT_MODE}. Pure — exported for
* reconstructors and tests.
*
* @param events The session log (or any prefix of it).
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns The folded mode name.
*/
export function foldMode(events: readonly SessionEvent[], end = events.length): string {
let mode = DEFAULT_MODE
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'mode/set') mode = event.data.mode
}
return mode
}
/** The mode the last logged request header shipped under, or `undefined` before the first header. */
function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldMode(events, lastHeader + 1)
}
/**
* `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
* pending-intent flush, the boundary narration, the `mode:policy` section,
* and the stable exit tool. UIs read mode flips off `session/event`; there is
* no live mirror.
*/
export class ModesService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned definitions, including `plan`. */
readonly resolved: ResolvedModes
/**
* The latest selected mode per session, awaiting its turn-boundary flush.
* `narrate` is true for user selections (the flush appends the coalesced
* notice when the header disagrees) and false for the exit tool's own
* switch, which narrates through its tool result instead.
*/
private readonly pendingIntents = new WeakMap<Session, { mode: string; narrate: boolean }>()
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
super(ctx, 'modes')
this.resolved = resolveConfig(config)
let disposed = false
// Boundary flushes ride the loop's interception seams, NOT the
// `session/event` feed: post-commit session observers are observe-only
// (an append from one would re-enter the publishing append and be
// contained away). Prompt-submit runs before the first assembly;
// turn-continuation runs after an ordinary step and before its successor.
// Request retries bypass turn-continuation, so the prepended request-error
// wrapper delegates through recovery (including async backoff), then
// flushes a retry decision before that waterfall returns to the loop. A
// flushed mode therefore lands before the prompt that should reflect it.
// Contained: policy must never block a prompt or turn; onBoundary can throw
// only when session.append rejects during teardown.
// Flush AFTER next() on every seam (the request-error wrapper below does
// the same): downstream listeners may await, and a `session/set_mode`
// arriving during that window must still shape the request this boundary
// precedes — a pre-next() flush would apply it one request late.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can capture this wrapper before Cordis unregisters it.
// Do not let that stale continuation mutate the session after its
// owning plugin fiber has been disposed.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'mode:policy',
order: 50,
text: context => (context.agent === undefined ? '' : this.activeDefinition(context.agent.session)?.definition.section ?? ''),
})
// Each configured definition contributes its own entry command to
// interactive front doors. The child activates only when `ctx.commands`
// is available, so a commands-less deployment composes dsh-mode unchanged.
ctx.inject(['commands'], (commandCtx) => {
for (const mode of this.resolved.definitions.keys()) {
commandCtx.commands.register({
name: mode,
description: `Enter ${mode} mode`,
input: { hint: '[message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
this.set(agent, mode)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: `Entering ${mode} mode (applies from the next step).` }
},
})
}
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (this.activeDefinition(agent.session)?.name !== PLAN_MODE) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// The review may outlive this plugin fiber (HMR unload while the user
// decides): the boundary listeners that would flush the switch are
// already gone, so a success result here would claim an exit that can
// never land. Fail the call instead; a remounted service still holds
// plan mode and the model re-presents.
if (disposed) {
throw new Error('the mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
// A custom-text-only answer is feedback, not consent — approval is
// exactly the approve option (an unknown selection never exits).
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// A boundary-applied switch, NOT a direct append: the loop may still
// execute further tool calls from the SAME assistant response after
// this one, and they were requested under the plan-shaped header — so
// the plan guidance keeps holding for that whole batch. The flush at
// this step's end appends
// the mode/set (still in-turn), so the next step's assembly reflects
// the exit; narrate: false — this result IS the narration.
this.pendingIntents.set(agent.session, { mode: DEFAULT_MODE, narrate: false })
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* The selectable mode vocabulary: {@link DEFAULT_MODE} first, then the
* configured definitions — the list a mode picker advertises.
*
* @returns Mode names, `default` first.
*/
list(): string[] {
return [DEFAULT_MODE, ...this.resolved.definitions.keys()]
}
/**
* The agent's mode state: the folded mode in force (a folded name the config
* no longer defines reads as {@link DEFAULT_MODE}) plus the pending
* user-selected intent awaiting its boundary flush, when one exists.
*
* @param agent The agent to read.
* @returns The current (effective) mode and the pending intent, if any.
*/
get(agent: Agent): { current: string; pending?: string } {
const current = this.activeDefinition(agent.session)?.name ?? DEFAULT_MODE
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { current } : { current, pending: pending.mode }
}
/**
* Select the agent's mode. Validates the name against {@link list} (loud on
* unknown; `default` is always a valid target), drops a no-op (target equals
* the pending intent, else the current fold), and otherwise records a
* pending intent flushed as a `mode/set` at the next turn boundary.
*
* @param agent The agent to switch.
* @param mode The target mode name.
*/
set(agent: Agent, mode: string): void {
if (mode !== DEFAULT_MODE && !this.resolved.definitions.has(mode)) {
throw new Error(`unknown mode "${mode}" — available modes: ${this.list().join(', ')}`)
}
const session = agent.session
const target = this.pendingIntents.get(session)?.mode ?? this.get(agent).current
if (mode === target) return
this.pendingIntents.set(session, { mode, narrate: true })
}
/** The folded mode's definition, or `undefined` for the default mode and for a folded name the config no longer defines. */
private activeDefinition(session: Session): { name: string; definition: ModeDefinition } | undefined {
const name = foldMode(session.events)
if (name === DEFAULT_MODE) return undefined
const definition = this.resolved.definitions.get(name)
if (definition === undefined) return undefined
return { name, definition }
}
/**
* One boundary pass for prompt submission, a normal successor, or a recovery
* retry: append a changed pending `mode/set` and one coalesced notice when the
* flushed mode differs from what the last logged request header told the
* model. Idempotent per boundary, so repeated dispatches flush once.
*/
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.mode
if (target === foldMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('mode/set', { mode: target })
// Clear the intent only after the append lands; a failed write remains
// pending so a later boundary can retry it.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = modeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target === DEFAULT_MODE
? 'The user switched this session back to the default mode.'
: `The user switched this session to ${target} mode.`
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'mode' },
}, { surfaceOp: 'append' })
}
}
export default ModesService
+9
View File
@@ -0,0 +1,9 @@
# plan/ — plan collaboration state
Plan mode is one logged, per-agent collaboration state. It is a single **product** package, not a generic mode registry or a capability-seam trio.
| Package | Role | ctx key |
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]`, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
+88
View File
@@ -0,0 +1,88 @@
# @deepseek-ai/dsh-plan-mode
Logged, per-agent plan collaboration state with deployment-owned guidance, a direct `/plan [message]` entry command, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
## Durable state
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state.
## Model and human surfaces
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
When `ctx.commands` is composed, the package registers `/plan [message]`. The command selects plan mode first. A non-empty argument is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance; bare `/plan` only changes state.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
## Configuration
```yaml
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Explore and design before presenting the complete
plan through exit_plan_mode.
```
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
## Model Experience
### Plan policy system prompt
#### What the model sees
While plan mode is active, the model sees the deployment's exact `section` text at prompt order 50; inactive mode contributes no text.
##### Configuration example
```markdown
You are in plan mode. Explore and design before presenting the complete plan through exit_plan_mode.
```
#### Token effect
Inactive mode adds no tokens; active mode adds the configured section to every request.
#### KV Cache effect
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
### Optional command message
#### What the model sees
`/plan` and its terminal result stay outside model history; a non-empty suffix becomes one trimmed user text block through `agent.steer()` after plan mode is selected.
#### Token effect
The suffix costs the same history tokens as submitting that text separately; a bare command adds none.
#### KV Cache effect
The user block is append-only conversation growth, while entering plan mode also changes the earlier policy section.
### Exit tool schema and review exchange
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the exit result and rejection returns feedback.
#### Token effect
The stable schema is paid according to ToolRegistry mode, and each plan argument and review result remains in conversation history.
#### KV Cache effect
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
## Known Limitations and Deferred Work
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-mode",
"description": "Session modes for the DeepSeek Harness: plan mode as a logged per-agent collaboration state with a guidance section and a user-reviewed exit",
"name": "@deepseek-ai/dsh-plan-mode",
"description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit",
"version": "0.0.1",
"private": true,
"type": "module",
+342
View File
@@ -0,0 +1,342 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review. It is
* independent of sandbox mode and approval policy; those enforcement axes do
* not read or write plan state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until a turn boundary because every session event
* is turn-enclosed. The service flushes before the affected request assembly
* on prompt submission, ordinary continuation, and request-recovery retry.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Notes:
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
* inactive through {@link foldPlanMode}.
*/
'plan/mode': { active: boolean }
}
}
declare module 'cordis' {
interface Context {
planMode: PlanModeService
}
}
/**
* The model-facing exit tool's name. It stays registered while plan mode is
* inactive so the request tool catalog is stable across transitions.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/** Deployment-owned plan guidance. */
export interface PlanModeConfig {
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
section: string
}
/** The review question's approve option label. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ 'The user may approve (carry out the plan from your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
* unknown fields fail at plugin load rather than silently shaping nothing.
*
* @param config Raw plugin config.
* @returns A detached validated config.
*/
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
const section = (config as Partial<PlanModeConfig>).section
if (typeof section !== 'string') {
throw new Error('PlanModeConfig needs a string `section`')
}
if (section.trim() === '') {
throw new Error('PlanModeConfig needs a non-empty `section`')
}
const unknown = Object.keys(config).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
}
return { section }
}
/**
* Whether plan mode is active after the first `end` events. The last
* `plan/mode` wins; a prefix with none is inactive.
*
* @param events The session log or any prefix of it.
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns Whether plan mode is active.
*/
export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
let active = false
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'plan/mode') active = event.data.active
}
return active
}
/** Plan state at the last logged request header, or `undefined` before the first header. */
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldPlanMode(events, lastHeader + 1)
}
/**
* `ctx.planMode`: owns logged plan state, boundary application and narration,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* UIs observe committed flips through `session/event`; there is no live mirror.
*/
export class PlanModeService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned guidance. */
private readonly section: string
/**
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
* true for user selections and false for the exit tool, whose result already
* narrates the transition.
*/
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
let disposed = false
// Boundary flushes use loop interception seams, not post-commit
// `session/event` observation. Flush after next(): a selection arriving
// while a downstream async listener awaits must still shape the request
// this boundary precedes. Failures are contained so policy cannot block a
// prompt or turn; a failed append remains pending for a later boundary.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can retain this wrapper after Cordis unregisters it.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'plan:policy',
order: 50,
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
? this.section
: '',
})
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter plan mode',
input: { hint: '[message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
},
})
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (!foldPlanMode(agent.session.events)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// A review may outlive this plugin fiber. Without boundary listeners,
// an approved result could never land, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// Keep plan guidance for the rest of this assistant tool batch. The
// silent intent flushes after the step, before the next assembly.
this.pendingIntents.set(agent.session, { active: false, narrate: false })
return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* Read the logged plan state and any selected state awaiting a boundary.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean } {
const active = foldPlanMode(agent.session.events)
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { active } : { active, pending: pending.active }
}
/**
* Select whether plan mode should be active from the next turn boundary.
* Repeated selection of the current or already-pending state is a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
*/
set(agent: Agent, active: boolean): void {
const session = agent.session
const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
if (active === target) return
this.pendingIntents.set(session, { active, narrate: true })
}
/** Flush one pending selection before the next request assembly. */
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
if (target === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('plan/mode', { active: target })
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}, { surfaceOp: 'append' })
}
}
export default PlanModeService
@@ -1,32 +1,27 @@
/** Package-owned durable mode-stream invariants. @module @deepseek-ai/dsh-mode/invariant */
/** Package-owned durable plan-mode invariants. @module @deepseek-ai/dsh-plan-mode/invariant */
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-mode'
const PACKAGE_NAME = '@deepseek-ai/dsh-plan-mode'
/** Cordis companion plugin name. */
export const name = 'mode-invariant'
export const name = 'plan-mode-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Validate one `mode/set` payload before it reaches the durable log: the mode
* is a non-empty bare name (config-declared vocabulary, not an opaque id), so
* an empty or non-string value can only be a writer bug folding it would
* silently select the default mode while the log claims otherwise.
*/
/** Validate one `plan/mode` payload before it reaches the durable log. */
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
if (event.type !== 'mode/set') return
const mode = (event.data as { mode?: unknown }).mode
if (typeof mode !== 'string' || mode.trim() === '' || mode.trim() !== mode) {
fail(`mode/set carries invalid mode ${JSON.stringify(mode)}; expected a non-empty trimmed name`)
if (event.type !== 'plan/mode') return
const active = (event.data as { active?: unknown }).active
if (typeof active !== 'boolean') {
fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install validation for loaded and newly appended mode selections. */
/** Install validation for loaded and newly appended plan-mode state. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(event, fail)
@@ -40,7 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
/* jscpd:ignore-end */
/**
* Register the mode invariant companion.
* Register the plan-mode invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
@@ -6,13 +6,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import ModesService, { PLAN_MODE, foldMode } from '@deepseek-ai/dsh-mode'
import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const PLAN_CONFIG = { modes: { plan: { section: 'Test plan mode instructions.' } } }
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
/**
* Full-loop integration: a scripted mock model drives the REAL mode plugin
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
* through the agent loop the pending-intent flush at the turn boundary, the
* assembly the soft layer shapes (the exit tool + mode section), and the
* `request/header` snapshots every transition leaves.
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
ctx.llm.registerAdapter(['mock'], adapter)
for (const name of ['read', 'write']) {
ctx.tools.register(defineTool({
@@ -73,15 +73,15 @@ describe('plan mode through the agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle (the ACP picker shape): the pending intent flushes at
// the first prompt-submit, BEFORE the first assembly.
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
const planMode = findEvent(log, 'plan/mode')
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(planMode.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(header.data.header.system).toContain('plan mode')
@@ -91,7 +91,7 @@ describe('plan mode through the agent loop', () => {
// axes). The mode itself stays plan throughout.
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(false)
expect(foldMode(log)).toBe(PLAN_MODE)
expect(foldPlanMode(log)).toBe(true)
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
@@ -105,16 +105,16 @@ describe('plan mode through the agent loop', () => {
agent.send([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(foldMode(agent.session.events)).toBe('default')
expect(foldPlanMode(agent.session.events)).toBe(false)
const first = findEvent(agent.session.events, 'request/header')
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'now plan' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
expect(foldMode(log)).toBe(PLAN_MODE)
expect(foldPlanMode(log)).toBe(true)
const notices = log.filter(event => event.type === 'context/message')
expect(notices).toHaveLength(1)
expect(findEvent(log, 'context/message').data.content).toEqual([
@@ -148,21 +148,21 @@ describe('plan mode through the agent loop', () => {
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
releaseRecovery.resolve(true)
await idle
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.modes.plan.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.modes.plan.section)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const modeSet = findEvent(log, 'mode/set')
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
expect(firstEnd?.seq).toBeLessThan(modeSet.seq)
expect(modeSet.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.modes.plan.section)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
@@ -1,35 +1,32 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ModeInvariant from '@deepseek-ai/dsh-mode/invariant'
import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(ModeInvariant)
await ctx.plugin(PlanModeInvariant)
return ctx
}
function event(mode: unknown): SessionEvent {
return { type: 'mode/set', seq: 0, time: 0, data: { mode } } as SessionEvent
function event(active: unknown): SessionEvent {
return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent
}
describe('mode stream invariants', () => {
it('accepts a plain non-empty trimmed mode name', async () => {
describe('plan-mode stream invariants', () => {
it('accepts either boolean state', async () => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event('plan')) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event('default')) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event(true)) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event(false)) }).not.toThrow()
})
it.each([
[42, /invalid mode 42/],
['', /invalid mode ""/],
[' plan ', /invalid mode " plan "/],
])('rejects an invalid durable mode selection', async (mode, message) => {
it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event(mode)) }).toThrow(message)
expect(() => { ctx.emit('session/event', {} as Session, event(active)) })
.toThrow(/expected a boolean/)
})
it('ignores unrelated dispatches and session events', async () => {
@@ -42,12 +39,12 @@ describe('mode stream invariants', () => {
}).not.toThrow()
})
it('rejects an invalid existing selection on late registration', async () => {
it('rejects invalid existing state on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.sessions.create().append('mode/set', { mode: '' })
ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(ModeInvariant).then(() => undefined)).rejects.toThrow(/invalid mode ""/)
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
})
})
@@ -9,14 +9,14 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfies ModeConfig
const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
/**
* Drives the REAL plugin: mounts `dsh-mode` beside real `SystemPrompt` and
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Turn boundaries are simulated by appending the real boundary events and
@@ -24,7 +24,7 @@ const PLAN_CONFIG = { modes: { plan: { section: TEST_PLAN_SECTION } } } satisfie
* exercise the separate `agent/request-error` wrapper.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { mode }: { mode?: string } = {}): Promise<Agent & { session: Session }> {
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
@@ -32,8 +32,8 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { mode }: { mode?:
inject: ['tools'],
}))
;(agent as { ctx?: Context }).ctx = scoped
// A seeded mode lands before the creation announcement, matching resume.
if (mode !== undefined) session.append('mode/set', { mode })
// Seeded plan state lands before the creation announcement, matching resume.
if (active !== undefined) session.append('plan/mode', { active })
// The loop announces creation after publication.
ctx.emit('agent/created', agent)
return agent
@@ -44,11 +44,11 @@ function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
}
async function setup(config: ModeConfig = PLAN_CONFIG): Promise<Context> {
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ModesService, config)
await ctx.plugin(PlanModeService, config)
return ctx
}
@@ -122,125 +122,83 @@ function execute(ctx: Context, name: string, agent?: Agent) {
}
describe('resolveConfig', () => {
it('requires the deployment to configure the plan instructions', () => {
expect(() => resolveConfig({ modes: {} }))
.toThrow('mode "plan" is required; put its model instructions in modes.plan.section')
expect(() => resolveConfig({} as ModeConfig))
.toThrow('mode "plan" is required; put its model instructions in modes.plan.section')
})
it('loads the configured plan instructions and further modes verbatim', () => {
const resolved = resolveConfig({ modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'review' },
} })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: TEST_PLAN_SECTION })
expect(resolved.definitions.get('review')).toEqual({ section: 'review' })
})
it('rejects the reserved default key loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, default: { section: 'policy' } } }))
.toThrow('"default" is reserved')
})
it('rejects an empty or untrimmed mode name loudly (the invariant would reject its selection)', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, '': { section: 'x' } } }))
.toThrow('mode name "" must be non-empty and trimmed')
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, ' review ': { section: 'x' } } }))
.toThrow('mode name " review " must be non-empty and trimmed')
})
it('rejects mode names that cannot also name their slash commands', () => {
for (const name of ['Review', 'review mode', '1-review', 'review!']) {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, [name]: { section: 'x' } } }))
.toThrow(`mode name ${JSON.stringify(name)} must match /^[a-z][a-z0-9_-]*$/u for its slash command`)
}
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } }))
it('requires string, non-empty plan instructions', () => {
expect(() => resolveConfig({} as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ modes: { plan: { section: ' ' } } }))
expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ section: ' ' }))
.toThrow('needs a non-empty `section`')
// Unknown keys fail loud — a tool allow/deny list and enforcement knobs
// are deliberately not part of the vocabulary, and a config still
// carrying one must not be silently accepted as if it shaped anything.
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', tools: ['read'] } as unknown as { section: string } } }))
.toThrow('unknown key(s) tools — a definition is { section }')
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 'bad', access: 'read-only' } as unknown as { section: string } } }))
.toThrow('unknown key(s) access — a definition is { section }')
})
it('returns a detached plan config', () => {
const config = { section: TEST_PLAN_SECTION }
const resolved = resolveConfig(config)
expect(resolved).toEqual(config)
expect(resolved).not.toBe(config)
})
it('rejects fields outside the plan policy config', () => {
expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
.toThrow('unknown key(s) tools — config is { section }')
})
})
describe('foldMode', () => {
it('folds an empty log to the default mode and takes the last mode/set otherwise', () => {
describe('foldPlanMode', () => {
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
const session = new Session(SessionId('fold'))
expect(foldMode(session.events)).toBe(DEFAULT_MODE)
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
session.append('mode/set', { mode: 'plan' })
expect(foldMode(session.events)).toBe('plan')
expect(foldPlanMode(session.events)).toBe(false)
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
session.append('plan/mode', { active: true })
expect(foldPlanMode(session.events)).toBe(true)
})
it('folds a prefix when `end` is given', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('mode/set', { mode: 'plan' })
session.append('mode/set', { mode: 'default' })
expect(foldMode(session.events, 1)).toBe('plan')
expect(foldMode(session.events, 0)).toBe(DEFAULT_MODE)
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
expect(foldPlanMode(session.events, 1)).toBe(true)
expect(foldPlanMode(session.events, 0)).toBe(false)
})
})
describe('ctx.modes: list/get/set', () => {
it('lists default first, then the configured definitions', async () => {
const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 's' } } })
expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
})
it('reads the folded mode, mapping a dropped definition to default', async () => {
describe('ctx.planMode: get/set', () => {
it('reads the folded state', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
agent.session.append('mode/set', { mode: PLAN_MODE })
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
agent.session.append('mode/set', { mode: 'retired' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false })
agent.session.append('plan/mode', { active: true })
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('rejects an unknown mode name loudly, naming the vocabulary', async () => {
it('selects inactive as the plan exit target', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(() => { ctx.modes.set(agent, 'nope') }).toThrow('unknown mode "nope" — available modes: default, plan')
})
it('accepts default as a target (exit-to-default is a valid write)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
agent.session.append('plan/mode', { active: true })
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, DEFAULT_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: false })
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, true)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
describe('the boundary flush', () => {
it('flushes the pending intent as a mode/set at turn/start', async () => {
it('flushes the pending intent as a plan/mode at turn/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
@@ -248,11 +206,11 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
// A downstream async listener (the shipped hooks listeners' shape): the
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the mode/set still
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await next()
return decision
})
@@ -261,17 +219,17 @@ describe('the boundary flush', () => {
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
// A downstream listener captured before disposal keeps the waterfall
// continuation alive across the unload; the resumed wrapper must not
// append through the disposed service.
@@ -285,23 +243,23 @@ describe('the boundary flush', () => {
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('contains an append failure at the retry boundary without changing its decision', async () => {
@@ -309,32 +267,32 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(warn).toHaveBeenCalledOnce()
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.modes.set(agent, DEFAULT_MODE)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates nothing before the first request header (the section is the state statement)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -343,7 +301,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end')
@@ -353,9 +311,9 @@ describe('the boundary flush', () => {
it('narrates a switch back to the default mode with the default wording', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
header(agent.session)
ctx.modes.set(agent, DEFAULT_MODE)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
@@ -363,12 +321,12 @@ describe('the boundary flush', () => {
it('stays silent when the header already reflects the flushed mode', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
header(agent.session)
agent.session.append('mode/set', { mode: DEFAULT_MODE })
ctx.modes.set(agent, PLAN_MODE)
agent.session.append('plan/mode', { active: false })
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -378,12 +336,12 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
// Only the flush's own mode/set append fails; the boundary event itself
// Only the flush's own plan/mode append fails; the boundary event itself
// lands (the loop appended it before the seam fires).
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
@@ -391,11 +349,11 @@ describe('the boundary flush', () => {
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
agent.session.append = original
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent).pending).toBeUndefined()
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('contains an append failure on the prompt-submit seam the same way', async () => {
@@ -403,15 +361,15 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'mode/set') throw new Error('backend gone')
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
expect(warn).toHaveBeenCalledOnce()
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
@@ -422,12 +380,12 @@ describe('the soft layer', () => {
const agent = await agentWithSession(ctx)
const defaultAssembly = await assembleFor(ctx, agent)
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(defaultAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
agent.session.append('mode/set', { mode: PLAN_MODE })
agent.session.append('plan/mode', { active: true })
const planAssembly = await assembleFor(ctx, agent)
expect(planAssembly.tools).toEqual(defaultAssembly.tools)
expect(planAssembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves an agent-less assembly untouched', async () => {
@@ -435,29 +393,20 @@ describe('the soft layer', () => {
registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
it('keeps the full toolset in plan mode and renders the configured mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('keeps exit_plan_mode visible in custom modes while rendering their guidance', async () => {
const ctx = await setup({ modes: { ...PLAN_CONFIG.modes, review: { section: 'reviewing' } } })
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'review' })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
})
it('leaves foreign assemble additions alone in any mode (no assemble-layer filtering at all)', async () => {
// Modes do not filter the deployment's registry or later assembly additions.
it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
// Plan guidance does not filter the registry or later assembly additions.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -466,9 +415,9 @@ describe('the soft layer', () => {
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read'])
const planning = await agentWithSession(ctx, 'planning', { mode: PLAN_MODE })
const planning = await agentWithSession(ctx, 'planning', { active: true })
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
const defaulted = await agentWithSession(ctx, 'defaulted')
@@ -488,13 +437,13 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
// The SDK documents the full binding set plus the exit — a mode never
// prunes capabilities; it restrains by the section's guidance alone.
// The SDK documents the full binding set plus the exit; plan mode never
// prunes capabilities and restrains through guidance alone.
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
@@ -511,9 +460,9 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'both' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
// The stable registry contribution reaches both surfaces: the exit tool
// is present on the wire AND in the SDK alongside the untouched toolset.
@@ -530,22 +479,22 @@ describe('the soft layer', () => {
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const withModes = new Context()
await withModes.plugin(SystemPrompt)
await withModes.plugin(ToolRegistry, { mode: 'code' })
await withModes.plugin(FakeRuntime)
await withModes.plugin(ModesService, PLAN_CONFIG)
registerNamedTools(withModes, ['read', 'write'])
const agent = await agentWithSession(withModes)
const defaultSdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
const withPlanMode = new Context()
await withPlanMode.plugin(SystemPrompt)
await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
await withPlanMode.plugin(FakeRuntime)
await withPlanMode.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(withPlanMode, ['read', 'write'])
const agent = await agentWithSession(withPlanMode)
const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(defaultSdk).toContain('read(args:')
expect(defaultSdk).toContain('write(args:')
expect(defaultSdk).toContain('exit_plan_mode(args:')
agent.session.append('mode/set', { mode: PLAN_MODE })
const planSdk = (await assembleFor(withModes, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
agent.session.append('plan/mode', { active: true })
const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
// Loading the mode plugin deliberately adds one stable binding compared
// Loading the plan-mode plugin deliberately adds one stable binding compared
// with a deployment that does not compose plan mode at all.
const bare = new Context()
await bare.plugin(SystemPrompt)
@@ -556,14 +505,6 @@ describe('the soft layer', () => {
expect(bareSdk).not.toContain('exit_plan_mode(args:')
expect(defaultSdk).not.toBe(bareSdk)
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'retired' })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
})
})
describe('no execution gating beyond the exit tool', () => {
@@ -577,70 +518,59 @@ describe('no execution gating beyond the exit tool', () => {
expect(defaulted.isError).toBe(false)
})
it('runs every call in plan mode untouched — modes restrain by guidance, enforcement knobs are separate axes', async () => {
it('runs every call in plan mode untouched — guidance and enforcement are separate axes', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'bash'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
for (const name of ['read', 'write', 'bash']) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
}
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agent = await agentWithSession(ctx, 'agent-1', { mode: 'retired' })
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
})
})
describe('per-mode slash commands', () => {
it('registers one entry command per configured mode only when a commands service is composed', async () => {
describe('/plan', () => {
it('registers only when a commands service is composed and optionally submits the next-step message', async () => {
const bare = await setup()
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup({ modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
const ctx = await setup()
await ctx.plugin(CommandService)
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
const steer = vi.fn()
;(agent as unknown as { steer: typeof steer }).steer = steer
expect(ctx.commands.list(agent)).toEqual([
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
{ name: 'plan', description: 'Enter plan mode', input: { hint: '[message]' } },
{ name: 'review', description: 'Enter review mode', input: { hint: '[message]' } },
])
const signal = new AbortController().signal
expect(await ctx.commands.execute(agent, '/mode', signal)).toBeUndefined()
const plan = await ctx.commands.execute(agent, '/plan draft the migration ', signal)
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
expect(plain).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
expect(plainSteer).not.toHaveBeenCalled()
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: PLAN_MODE })
expect(steer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
const review = await ctx.commands.execute(agent, '/review', signal)
expect(review).toEqual({ kind: 'success', text: 'Entering review mode (applies from the next step).' })
expect(ctx.modes.get(agent)).toEqual({ current: DEFAULT_MODE, pending: 'review' })
expect(steer).toHaveBeenCalledOnce()
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
})
it('removes every contributed command when the mode plugin is disposed', async () => {
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(ModesService, { modes: {
plan: { section: TEST_PLAN_SECTION },
review: { section: 'Review mode instructions.' },
} })
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan', 'review'])
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
await fiber.dispose()
@@ -661,7 +591,7 @@ describe('exit_plan_mode', () => {
},
})
}
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
return { ctx, agent, asked }
}
@@ -708,16 +638,16 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
}
expect(asked).toHaveLength(0)
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
@@ -725,7 +655,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
@@ -735,10 +665,10 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
// Boundary-applied, not a direct append: the fold stays plan until the
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
@@ -760,7 +690,7 @@ describe('exit_plan_mode', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(ExitRuntime)
await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
@@ -769,7 +699,7 @@ describe('exit_plan_mode', () => {
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
},
})
const agent = await agentWithSession(ctx, 'code-mode-exit', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
@@ -791,7 +721,7 @@ describe('exit_plan_mode', () => {
arguments: { plan },
isError: false,
})
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
@@ -801,15 +731,15 @@ describe('exit_plan_mode', () => {
// Calls of the SAME assistant response (no boundary between) were
// requested under the plan-shaped header — the fold stays plan for that
// whole batch; the boundary flush is what flips the next step.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe(TEST_PLAN_SECTION)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
expect(afterExit.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
it('the exit flush narrates nothing — the tool result is the narration', async () => {
@@ -817,7 +747,7 @@ describe('exit_plan_mode', () => {
header(agent.session)
await callExit(ctx, agent)
await boundary(ctx, agent, 'step/end')
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -826,7 +756,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keep planning without feedback returns the generic corrective error', async () => {
@@ -841,7 +771,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('requires exactly the single Approve selection', async () => {
@@ -849,7 +779,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats custom text alongside Approve as feedback, not consent', async () => {
@@ -857,7 +787,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats duplicate review answer items as non-consent', async () => {
@@ -871,7 +801,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a missing answer item reads as keep-planning', async () => {
@@ -900,13 +830,13 @@ describe('exit_plan_mode', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
ctx.userInteraction.registerProvider({
ask: () => new Promise((resolve) => { answer = resolve }),
})
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const pending = callExit(ctx, agent)
// Let execute reach the review await, then unload the plugin (HMR) and
// only afterwards approve. The boundary listeners are gone, so a success
@@ -916,8 +846,8 @@ describe('exit_plan_mode', () => {
answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
const result = await pending
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
@@ -926,7 +856,7 @@ describe('exit_plan_mode', () => {
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('presents the call as a generic card titled by the plan first heading', async () => {
@@ -963,7 +893,7 @@ describe('HMR disposal', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
@@ -972,7 +902,7 @@ describe('HMR disposal', () => {
await releaseRecovery.promise
return { action: 'retry' }
})
ctx.modes.set(agent, PLAN_MODE)
ctx.planMode.set(agent, true)
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
await recoveryEntered.promise
@@ -980,25 +910,25 @@ describe('HMR disposal', () => {
releaseRecovery.resolve(true)
expect(await recovery).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-recovery')
ctx.modes.set(agent, PLAN_MODE)
expect(ctx.get('modes')).toBeInstanceOf(ModesService)
ctx.planMode.set(agent, true)
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('mode:policy')
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
await fiber.dispose()
expect(ctx.get('modes')).toBeUndefined()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('mode:policy')
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})
+4 -4
View File
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -25,7 +25,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-mode` mounted, `session/new`/`session/load` advertise `availableModes`/`currentModeId` and `session/set_mode` records the pending intent (optimistic `current_mode_update`; the logged `mode/set` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated``{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified on each logged `mode/set` that differs from the last sent (covers the `exit_plan_mode` tool flipping the session back). |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): the picker maps 1:1 onto `dsh-mode`'s vocabulary — `ctx.modes.list()` fills `availableModes`, `session/set_mode` calls `set()` (pending intent, flushed at the turn boundary), and `current_mode_update` tracks both the optimistic echo and every logged flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-modes / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks
+2 -2
View File
@@ -38,7 +38,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-mode": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -62,7 +62,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+40 -30
View File
@@ -67,9 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-mode'
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
// the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-plan-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -100,6 +100,18 @@ function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
const DEFAULT_SESSION_MODE_ID = 'default'
const PLAN_SESSION_MODE_ID = 'plan'
const AVAILABLE_SESSION_MODES = [
{ id: DEFAULT_SESSION_MODE_ID, name: DEFAULT_SESSION_MODE_ID },
{ id: PLAN_SESSION_MODE_ID, name: PLAN_SESSION_MODE_ID },
]
/** Map plan state onto ACP's named collaboration-mode protocol. */
function sessionModeId(active: boolean): string {
return active ? PLAN_SESSION_MODE_ID : DEFAULT_SESSION_MODE_ID
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
@@ -298,8 +310,8 @@ interface SessionRecord {
/**
* The last mode id this session sent to the client (advertised at
* session/new+load, echoed optimistically on session/set_mode, re-notified on
* each logged `mode/set` that differs). `undefined` when dsh-mode is not
* composed no mode surface is advertised, so nothing is ever notified.
* each logged `plan/mode` that differs). `undefined` when dsh-plan-mode is
* not composed, so no mode surface is advertised or notified.
*/
lastModeId: string | undefined
/** Session-local provider/model selection and the current step snapshot. */
@@ -558,21 +570,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// --- Session modes (dsh-mode, opportunistic) ------------------------------
// The mode PICKER is dsh-mode's ACP surface (the plan-mode Agent Note): advertised
// as `modes` on session/new + session/load, switched via session/set_mode —
// optimistic `current_mode_update` (the pending mode IS the user's
// selection; the logged `mode/set` follows at the turn boundary) — and
// re-notified on each logged flip that differs from the last sent (covers
// the exit_plan_mode tool flipping the session back). Environment knobs are
// NOT modes; they stay `session/set_config_option`.
// --- Session modes (dsh-plan-mode, opportunistic) -------------------------
// ACP's generic mode picker projects the one plan capability as the fixed
// `default` / `plan` vocabulary. A selection is echoed optimistically; the
// logged `plan/mode` follows at the boundary and tool-driven exits are
// re-notified from that event. Environment knobs remain config options.
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
const modes = ctx.get('modes')
if (modes === undefined) return undefined
const { current, pending } = modes.get(agent)
const planMode = ctx.get('planMode')
if (planMode === undefined) return undefined
const { active, pending } = planMode.get(agent)
return {
availableModes: modes.list().map(name => ({ id: name, name })),
currentModeId: pending ?? current,
availableModes: AVAILABLE_SESSION_MODES,
currentModeId: sessionModeId(pending ?? active),
}
}
@@ -601,16 +610,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
// Re-notify from the EVENT's value, not from modes.get(): the service
// Re-notify from the EVENT's value, not from planMode.get(): the service
// holds one coalesced pending slot (every flush reads the latest
// selection, so a flush can never be stale against the picker), and for
// any other writer — the exit tool, a test, a foreign plugin — the logged
// value IS the truth the picker should track, in log order. Inside the
// containment `finally` like the prompt settlement: a throwing presenter
// must not desync the picker.
if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
rec.lastModeId = event.data.mode
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
if (event.type === 'plan/mode') {
const modeId = sessionModeId(event.data.active)
if (modeId !== rec.lastModeId) {
rec.lastModeId = modeId
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: modeId } })
}
}
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
@@ -910,16 +922,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
const modes = ctx.get('modes')
if (modes === undefined) throw invalidParams('session modes are not composed in this deployment')
try {
modes.set(rec.agent, params.modeId)
} catch (error) {
// ModesService.set throws only Error (its unknown-name validation).
throw invalidParams((error as Error).message)
const planMode = ctx.get('planMode')
if (planMode === undefined) throw invalidParams('session modes are not composed in this deployment')
if (params.modeId !== DEFAULT_SESSION_MODE_ID && params.modeId !== PLAN_SESSION_MODE_ID) {
throw invalidParams(`unknown session mode ${JSON.stringify(params.modeId)} — available modes: default, plan`)
}
planMode.set(rec.agent, params.modeId === PLAN_SESSION_MODE_ID)
// Optimistic echo: the pending mode IS the user's selection; the logged
// `mode/set` lands at the next turn boundary and, matching lastModeId,
// `plan/mode` lands at the next turn boundary and, matching lastModeId,
// is not re-notified. A no-op selection (already current) echoes too —
// cheap, idempotent, and the picker settles regardless.
rec.lastModeId = params.modeId
+3 -3
View File
@@ -17,7 +17,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import ModesService from '@deepseek-ai/dsh-mode'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -192,7 +192,7 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/** Plug the REAL `dsh-mode` plugin so a test can drive the session-mode picker. */
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
@@ -229,7 +229,7 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(ModesService, { modes: { plan: { section: 'Test plan mode instructions.' } } })
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
+7 -7
View File
@@ -13,7 +13,7 @@ function modeUpdates(updates: CapturedUpdate[]): string[] {
.map(update => update.currentModeId)
}
describe('acp bridge — session modes (dsh-mode)', () => {
describe('acp bridge — plan mode projection', () => {
let storageDir: string
let harness: BridgeHarness | undefined
let loader: BridgeHarness | undefined
@@ -26,7 +26,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await rm(storageDir, { recursive: true, force: true })
})
it('advertises no mode surface and rejects session/set_mode when dsh-mode is not composed', async () => {
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -55,15 +55,15 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
expect(modeUpdates(harness.updates)).toEqual(['plan'])
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(harness.ctx.modes.get(agent)).toEqual({ current: 'default', pending: 'plan' })
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('rejects an unknown mode id with the service validation message', async () => {
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
.rejects.toMatchObject({ message: expect.stringContaining('unknown mode "nope"') as string })
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
expect(modeUpdates(harness.updates)).toEqual([])
})
@@ -74,7 +74,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(true)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
expect(modeUpdates(harness.updates)).toEqual(['plan'])
})
@@ -87,7 +87,7 @@ describe('acp bridge — session modes (dsh-mode)', () => {
// A writer other than the picker (exit_plan_mode's execute) appends the
// flip back; the bridge must re-notify the client off the logged event.
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.session.append('mode/set', { mode: 'default' })
agent.session.append('plan/mode', { active: false })
// The notification crosses the in-memory JSON-RPC transport asynchronously.
await new Promise(resolve => setTimeout(resolve, 20))
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
+1 -1
View File
@@ -42,7 +42,7 @@
"path": "../user-interaction"
},
{
"path": "../../mode/mode"
"path": "../../plan/plan-mode"
},
{
"path": "../../session-persistence/session-persistence"
+1 -1
View File
@@ -22,7 +22,7 @@ The terminal and ACP app bundles mount this service with their consuming front d
#### What the model sees
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) submits the optional message in `/plan [message]` after selecting the mode.
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
#### Token effect
+1 -1
View File
@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-mode`](../../mode/mode/README.md#per-mode-slash-commands) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
+7 -7
View File
@@ -164,9 +164,9 @@ importers:
'@deepseek-ai/dsh-lsp-local':
specifier: workspace:*
version: link:../packages/lsp/lsp-local
'@deepseek-ai/dsh-mode':
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:*
version: link:../packages/mode/mode
version: link:../packages/plan/plan-mode
'@deepseek-ai/dsh-permission':
specifier: workspace:*
version: link:../packages/ui/permission
@@ -1697,7 +1697,7 @@ importers:
specifier: ^4.4.3
version: 4.4.3
packages/mode/mode:
packages/plan/plan-mode:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
@@ -2695,9 +2695,9 @@ importers:
'@deepseek-ai/dsh-llm-retry':
specifier: workspace:^
version: link:../../llm/llm-retry
'@deepseek-ai/dsh-mode':
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../mode/mode
version: link:../../plan/plan-mode
'@deepseek-ai/dsh-permission':
specifier: workspace:^
version: link:../permission
@@ -3404,9 +3404,9 @@ importers:
'@deepseek-ai/dsh-llm-retry':
specifier: workspace:^
version: link:../../packages/llm/llm-retry
'@deepseek-ai/dsh-mode':
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../packages/mode/mode
version: link:../../packages/plan/plan-mode
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../packages/util/paths
+1 -1
View File
@@ -39,7 +39,7 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mode": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
+5 -5
View File
@@ -70,7 +70,7 @@ const GROUP_ORDER = [
'web',
'spill',
'todo',
'mode',
'plan',
'cordis',
'hooks',
'session-persistence',
@@ -172,12 +172,12 @@ const SERVICE_ROLES: ServiceRole[] = [
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'modes',
pkg: 'mode',
title: 'Session-mode state',
key: 'planMode',
pkg: 'plan-mode',
title: 'Plan collaboration state',
mode: 'core',
consumers: ['acp'],
note: 'Folds logged per-agent mode/set state, flushes user flips at turn boundaries, renders deployment-owned guidance, and keeps the plan-exit schema stable across modes.',
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
},
{
key: 'commands',
+1 -1
View File
@@ -31,7 +31,7 @@ const GROUP_ORDER = [
'spill',
'timeout',
'todo',
'mode',
'plan',
'cordis',
'hooks',
'session-persistence',
+7 -7
View File
@@ -19,7 +19,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import ModesService from '@deepseek-ai/dsh-mode'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
@@ -174,16 +174,16 @@ const TOOL_PACKAGES: ToolPackage[] = [
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-mode',
dir: 'mode',
source: 'packages/mode/mode/src/index.ts',
pkg: '@deepseek-ai/dsh-plan-mode',
dir: 'plan-mode',
source: 'packages/plan/plan-mode/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'],
writes: ['tool/call', 'mode/set back to default on an approved review', 'tool/result'],
writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'],
async mount(ctx) {
await ctx.plugin(ModesService, { modes: { plan: { section: 'Tool catalog schema harvest.' } } })
await ctx.plugin(PlanModeService, { section: 'Tool catalog schema harvest.' })
},
note:
'exit_plan_mode stays in the model-facing schema in every session mode so transitions add no tool-catalog churn on top of the mode-section change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval flips the logged session mode back to default at the step boundary.',
'exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
+2 -2
View File
@@ -51,7 +51,7 @@
"./packages/context/*/src/invariant.ts",
"./packages/goal/*/src/invariant.ts",
"./packages/guard/*/src/invariant.ts",
"./packages/mode/*/src/invariant.ts",
"./packages/plan/*/src/invariant.ts",
"./packages/subagent/*/src/invariant.ts",
"./packages/tasks/*/src/invariant.ts",
"./packages/workflow/*/src/invariant.ts",
@@ -89,7 +89,7 @@
"./packages/context/*/src",
"./packages/goal/*/src",
"./packages/guard/*/src",
"./packages/mode/*/src",
"./packages/plan/*/src",
"./packages/subagent/*/src",
"./packages/tasks/*/src",
"./packages/workflow/*/src",
+1 -1
View File
@@ -102,7 +102,7 @@
{ "path": "./packages/workflow/tool-workflow" },
{ "path": "./packages/workflow/tool-ralph" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/mode/mode" },
{ "path": "./packages/plan/plan-mode" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/hooks/hook-protocol" },
+1 -1
View File
@@ -116,7 +116,7 @@
{ "path": "./packages/workflow/tool-workflow" },
{ "path": "./packages/workflow/tool-ralph" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/mode/mode" },
{ "path": "./packages/plan/plan-mode" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/hooks/hook-protocol" },