fix(cli): close shared config review gaps

This commit is contained in:
Turtle
2026-07-30 14:56:39 +08:00
parent 5a490553bc
commit a51143bded
51 changed files with 277 additions and 171 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md
2026-07-17-dedicated-full-screen-tui-front-door.md: 47f5b7f94ce56d592c6a7897d8bcd470e12a0a60
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: bbbfaafd8c5fc382dad070daf1eba26a0c6dcedd
2026-07-17-dedicated-full-screen-tui-front-door.md: 10564b110cc2e56615bde86830c0d39d5a7cee38
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 4624a4db3db598793eee257f829a080f4d7ad711
@@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session
DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior.
There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition, with the Code Mode overlay in `examples/code-mode`. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol.
There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/config/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition. Non-interactive tasks use the official headless surface; ACP remains a separate automation protocol and owns the supported Code Mode demo.
The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1.
@@ -14,7 +14,7 @@ Status: implemented
DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。
只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装Code Mode overlay 则位于 `examples/code-mode`。非交互任务使用 `@deepseek-ai/dsh-cli-demo`ACP 仍是独立的自动化协议
只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/config/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装。非交互任务使用官方 headless 界面;ACP 仍是独立的自动化协议,并拥有受支持的 Code Mode demo
所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。
@@ -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 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95
2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7
2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a
2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a
@@ -14,10 +14,10 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The official dsh surfaces consume its two optional files; the demo bins boot their committed trees verbatim:
- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`.
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped.
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op.
- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
@@ -14,10 +14,10 @@ Status: implemented
**`dsh` CLI`apps/cli`npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web``-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的官方界面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include``PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config``insert` 追加配置项,未匹配的 id 记录警告并跳过
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include``PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config``insert` 追加配置项,未匹配的 id 静默不执行任何操作
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
@@ -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 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
2026-07-20-remove-stdio-and-echo-agents.md: 72b8f5e0eb161acb7152d1b1dd4e455addf36dbf
2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479
2026-07-20-remove-stdio-and-echo-agents.md: b578e49f004cabf0e37385ea06a769ced5388217
2026-07-20-remove-stdio-and-echo-agents.zh.md: 3d8eecf73f17f3b62dc4b35b0f3c1af03f6f3a86
@@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com
The remaining application roles are explicit:
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with the Code Mode overlay in `examples/code-mode` and PTY plus terminal-snapshot coverage in `apps/cli/tests/`.
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`.
- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures.
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations.
@@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt
TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references.
The TUI PTY smoke includes the Code Mode overlay composition, while `examples/cordis-agent/tests/keyless-smoke.e2e.ts` provides a minimal PTY boot over the real Cordis-agent Loader tree. The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior.
The built `dsh` bin rejects a piped TUI launch before Loader boot and points at its one-shot `-p` mode; `apps/cli/tests/built-bin.e2e.ts` pins that path, while `cli-demo`'s built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior.
## Alternatives considered
@@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个
保留的应用角色均有明确归属:
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml``tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 终端快照覆盖则位于 `apps/cli/tests/`
- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。
@@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换
TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat``StdioRuntime` 引用。
TUI PTY 冒烟测试包含 Code Mode 覆盖层组装,而 `examples/cordis-agent/tests/keyless-smoke.e2e.ts` 会基于真实 Cordis-agent Loader 目录树执行最小 PTY 启动。构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。
构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向其单次 `-p` 模式;`apps/cli/tests/built-bin.e2e.ts` 固定了该执行路径,而 `cli-demo` 的 built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。
## 曾考虑的替代方案
@@ -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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md
2026-07-29-shared-base-config-overlays.md: 4b9b8de2f07bf1747ed7aa50d67740df768c7491
2026-07-29-shared-base-config-overlays.zh.md: c0ccc5880909afd1dec0cbb387067a3d72c1ae16
2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7
2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d
@@ -24,7 +24,7 @@ Precedence is list order, last write winning per row: base, then the surface ove
A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records.
`examples/tui-agent`, `examples/cordis-agent`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and `examples/code-mode` survives as a genuine example: an overlay that patches `tools.mode` and inserts the code runtime.
`examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`.
## Alternatives considered
@@ -46,7 +46,7 @@ A patch whose `id` matches no row stays a no-op rather than an error. That is de
## Verification
Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`.
Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. Code Mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application.
All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`).
@@ -24,7 +24,7 @@ Status: implemented
patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop``CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。
`examples/tui-agent``examples/cordis-agent``packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/``examples/code-mode` 作为一个名副其实的示例保留下来:一个 patch `tools.mode` 并 insert code runtime 的 overlay。
`examples/tui-agent``examples/cordis-agent``examples/code-mode``packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP overlay。
## 备选方案
@@ -46,7 +46,7 @@ patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆
## 验证
组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`
组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`Code Mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用
全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。
@@ -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 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847
2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
@@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
@@ -8,7 +8,7 @@ Status: proposed
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'``apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` 没有对应的全局根目录`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit``malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc"version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
+3
View File
@@ -32,6 +32,8 @@ flowchart LR
cfg --> plugin_tui_llm_pi_ai
plugin_tui_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"]
@@ -114,6 +116,7 @@ flowchart LR
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
+9 -1
View File
@@ -70,7 +70,15 @@
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.getBuiltinModule('node:path').join(process.env.DSH_HOME || process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh'), 'sessions')
root: !!js >-
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
# TUI consumes this shared session capability. Its launcher supplies a unique
# process-local path; non-TUI surfaces disable the row in their overlay.
- id: session-query-sqlite
name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
+1 -7
View File
@@ -50,8 +50,7 @@
config:
cwd: !!js process.cwd()
# The shipped TUI presents the native tool registry. `examples/code-mode` is the
# overlay that switches this row to the `run_code` transport.
# The shipped TUI presents the native tool registry.
- id: tools
config:
mode: native
@@ -78,11 +77,6 @@
# The derived query index behind `/resume`. The launcher provides a unique
# process-local path because this SQLite backend has one writer owner; the
# project-local fallback applies when no launcher sets the typed slot.
- id: session-query-sqlite
name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
+3 -7
View File
@@ -14,9 +14,9 @@
- id: hmr
disabled: true
- id: system-prompt
config:
persona: ''
# Session query is a TUI capability; Web owns its own session presentation.
- id: session-query-sqlite
disabled: true
- id: tools
config:
@@ -26,10 +26,6 @@
# once the web UI owns the choice per session.
mode: !!js process.env.DSH_TOOLS_MODE
- id: agent-loop
config:
agents: []
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
+1
View File
@@ -71,6 +71,7 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+3 -35
View File
@@ -12,7 +12,6 @@ import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { FiberState } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
@@ -88,14 +87,6 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
})
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/**
* Value mirror of cordis's `FiberState` const enum members the sweep needs
* (a const enum has no runtime object to import; same rationale as the
* client-side mirror in dsh-client-web).
*/
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shared base config the Loader includes. */
@@ -174,10 +165,8 @@ export class AppCLIEntry {
}
/**
* Compose the patch set from the non-yml config sources: computed
* engineering defaults (the global session root), profile json (user
* config, overriding those defaults), CLI flags, and the resolved frontend
* dist. Patches replace a row's config wholesale, so each patched row's yml
* Compose the patch set from profile json, CLI flags, and the resolved
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
* static values are re-read here (bypass parse) and merged under the overrides.
*/
private composePatches(): void {
@@ -189,7 +178,6 @@ export class AppCLIEntry {
overrides.set(entryId, bag)
}
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
@@ -215,7 +203,6 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
@@ -241,28 +228,9 @@ export class AppCLIEntry {
})
}
/**
* Shared boot catches import failures, installFailLoud catches late apply
* rejections, and the all-ACTIVE sweep
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
/** Install the diagnostic for plugin rejections that happen after settled boot. */
private assertBoot(): void {
installFailLoud('dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
const state = entry.fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/**
+3
View File
@@ -126,6 +126,9 @@ Examples:
dsh --resume <id> continue a past session
`)
.exitOverride()
// Stop parent options at a subcommand boundary so `web --config` belongs to
// Web while `--config ... web` remains a leaked default-surface option.
.enablePositionalOptions()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
+8 -8
View File
@@ -10,8 +10,8 @@
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta`
* ({@link runMeta}) is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` ({@link runSkillSession}) are fresh
* sessions whose first turn auto-invokes a bundled skill. After boot, the
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
* session whose first turn auto-invokes a bundled skill. After boot, the
* agent's system prompt is told the path to this harness checkout so it can
* find its own source.
* @module @deepseek-ai/dsh/tui
@@ -146,7 +146,7 @@ export async function runTui(
const app: { current?: Context } = {}
// Resume always enters the default surface because meta rejects parent
// options, including `--resume`. The resumed session already persists its cwd.
const resumeArgs = (sessionId: string, _targetCwd?: string): string[] => [
const resumeArgs = (sessionId: string): string[] => [
`--resume=${sessionId}`,
// Both config flags must survive the handoff: resuming into a different
// tree than the session was created in would silently change the agent.
@@ -167,7 +167,7 @@ export async function runTui(
process.execPath,
...process.execArgv,
entry,
...resumeArgs(sessionId, cwd),
...resumeArgs(sessionId),
]
// `execve` inherits the cwd, and the target session may belong to another
// workspace. Enter it BEFORE teardown commits: an unreachable directory
@@ -199,14 +199,14 @@ export async function runTui(
const replaceTree = configReplace !== undefined
const patches = replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...config === undefined
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)),
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
]
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG,
resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
patches,
(hostCtx) => {
// The launcher owns session identity and the exit line: a config-mounted
@@ -230,7 +230,7 @@ export async function runTui(
rm(`${queryIndexPath}-wal`, { force: true }),
rm(`${queryIndexPath}-shm`, { force: true }),
])
}, 'launcherSessionQueryPath.cleanup')
}, `${SESSION_QUERY_SQLITE_PATH_KEY}.cleanup`)
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
// Seed the first turn only for a fresh session, so resuming never
// re-invokes the skill.
+2 -3
View File
@@ -31,10 +31,9 @@ describe('parseDshArgs', () => {
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
expect(parse(['meta'])).toEqual({ mode: 'meta' })
// Credential setup is option-free: it writes the Harness-home .env, so
// there is nothing for a flag to select.
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
// Host/port are unvalidated pass-throughs (the webserver schema gates them
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
@@ -72,7 +71,7 @@ describe('parseDshArgs', () => {
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
// `upgrade` take no options: any leaked default-surface flag is a
// `upgrade` takes no options: any leaked default-surface flag is a
// mistyped invocation, not a silently-dropped input.
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
@@ -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 docs/cordis-tutorial/01-first-plugin.md
01-first-plugin.md: 0af2b8a292c2968c37e633d620192414479caafc
01-first-plugin.zh.md: cbea3ca2f7d875f481ef6a17749a5fac9774de1b
01-first-plugin.md: 39f140b91d4c9f8cc197df4d08bd2c4c168cc858
01-first-plugin.zh.md: 0715cd525f980ab237e65baf6982192d2e0c286c
+1 -1
View File
@@ -48,7 +48,7 @@ The process exits on its own once nothing is left running. What happened:
2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin.
3. Cordis called your `apply(ctx)`.
There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../apps/cli/config/base.cordis.yml), for example, is a longer plugin composition.
There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../apps/cli/config/tui.cordis.yml), for example, is a longer plugin composition.
## The two other plugin shapes
+1 -1
View File
@@ -48,7 +48,7 @@ hello from my first plugin
2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。
3. Cordis 调用你的 `apply(ctx)`
你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../apps/cli/config/base.cordis.yml) 就是一个更长的插件组合。
你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../apps/cli/config/tui.cordis.yml) 就是一个更长的插件组合。
## 其他两种插件形态
+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 examples/README.md
README.md: 61aff168c656082d20c12416b62512ac17f445cb
README.zh.md: 50fcaae6ee2997055d3c97480f2e5bed0983fc5e
README.md: a502f34128da497586d593f64d0ce1c05f68a067
README.zh.md: e3c111bb6a8f67899b6f434345baa3b47640b7ee
+1 -7
View File
@@ -10,12 +10,6 @@ A non-interactive agent demo that accepts one positional task, runs one complete
Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite.
## code-mode
An **overlay** over the shipped TUI that reduces the model-facing registry to the `run_code` transport, so the model batches tool work into TypeScript programs instead of one call per turn.
Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [../apps/cli/README.md](../apps/cli/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`.
## jsonrpc-agent
An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md).
@@ -30,6 +24,6 @@ Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or th
An agent exposed as an **Agent Client Protocol (ACP)** automation server over JSON-RPC stdio, via [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo). Programmatic clients create fresh sessions, send text prompts, consume committed assistant text, answer one-shot permission requests, and cancel work. It owns the ACP keyless snapshot suite.
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 protocol and snapshot-test contracts.
Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode` 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 protocol and snapshot-test contracts.
The default `cordis.yml` composes [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), and [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval). `workspace-write` confines bash and filesystem mutations to each session workspace; a wider retry becomes a one-shot machine permission request over ACP.
+1 -7
View File
@@ -10,12 +10,6 @@
运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。
## code-mode
叠加在交付 TUI 之上的 **overlay**:把面向模型的注册表收敛为 `run_code` 这一个传输,使模型把工具工作批量写进 TypeScript 程序,而不是每轮一次调用。
运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [../apps/cli/README.md](../apps/cli/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/config/tui.cordis.yml`](../apps/cli/config/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`
## jsonrpc-agent
通过 Python SDK 驱动的无人值守编码 agentJSON-RPC stdio、仅前台 `bash``read`/`write`/`edit`、一个前台 `subagent``todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。
@@ -30,6 +24,6 @@
作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。
运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。
运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。
默认 `cordis.yml` 组合 [`@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)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。
+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 examples/acp-agent/README.md
README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961
README.zh.md: 84482aad8352ab38527dcf4d9e1bfefc8d496c91
README.md: a37954d4d52900413a506f227759a0a7dd2a25d0
README.zh.md: a38431ea2f01fe16ff39b5dd00ec23e881713f5d
+1 -1
View File
@@ -6,7 +6,7 @@ Automation-oriented [Agent Client Protocol](https://agentclientprotocol.com) ser
```sh
pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport
pnpm run demo:code-mode # same protocol with the Code Mode tool transport
```
The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK, and [`web.cordis.yml`](web.cordis.yml) adds the web seam, the local fetch provider, `web_fetch`, and a loopback HTML fixture server for the web-fetch snapshot.
+1 -1
View File
@@ -6,7 +6,7 @@
```sh
pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport
pnpm run demo:code-mode # same protocol with the Code Mode tool transport
```
该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。
+1 -1
View File
@@ -1,6 +1,6 @@
# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool,
# `run_code`, plus its generated TypeScript SDK prompt. The app bin selects this
# overlay for `demo:code-mode acp` and snapshot recording, and selects the sibling
# overlay for `demo:code-mode` and snapshot recording, and selects the sibling
# replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app
# config, so unchanged base fields are restated below.
- id: base
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"version": "0.0.1",
"type": "module",
"description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.",
"description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exportslib. Not a build target.",
"dependencies": {
"@cordisjs/plugin-hmr": "workspace:*",
"@cordisjs/plugin-include": "workspace:*",
+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 packages/host/apiproxy/README.md
README.md: 1a62323a6b47a0d59dc697ada97144b3c523f64a
README.zh.md: b3be5537738c3071dd0fb0fbcf2953eba1804749
README.md: 6182bba7b64e2692d08ccfd95cd3bc91e733cebd
README.zh.md: edc890ce9b2977a5ab2ec4763225bc08e29ab7ee
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/config/base.cordis.yml).
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
## Contract layer (`/api`)
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
## 契约层(`/api`
+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 packages/ui/app-boot/README.md
README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31
README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
+5 -4
View File
@@ -10,8 +10,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
@@ -23,10 +25,10 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
## Personal config
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
@@ -44,4 +46,3 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.
+5 -4
View File
@@ -10,8 +10,10 @@
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
@@ -23,10 +25,10 @@
## 个人配置
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
@@ -44,4 +46,3 @@
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml``cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。
+27 -1
View File
@@ -10,7 +10,7 @@ import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context } from 'cordis'
import { Context, type FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -192,6 +192,31 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/**
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
* @param ctx - The settled application root.
* @param binName - Diagnostic prefix.
*/
export function assertEntriesActive(ctx: Context, binName: string): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
if (entry.fiber.state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
}
}
if (failures.length > 0) {
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
* tree settles. Entry names load through the Loader's internal module loader
@@ -231,6 +256,7 @@ export async function boot(
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
assertEntriesActive(ctx, binName)
return ctx
}
+28 -1
View File
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
@@ -212,6 +212,33 @@ describe('boot', () => {
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('rejects a settled tree with a pending inject and names every missing service', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
})
it('uses singular diagnostics for one missing pending dependency', () => {
const ctx = {
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
})
it('reports unknown pending dependencies and unexpected fiber states', () => {
const entries = [
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
]
const ctx = {
loader: { entries: () => entries },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
})
})
describe('addHarnessSourceSection', () => {
+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 packages/ui/tui/README.md
README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4
README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82
README.md: d3ea5b41e8398c92853c6f96160129d7b1c475ed
README.zh.md: 9ee509abe2cf5bf6360b7c91d318b2b69745d224
+1 -1
View File
@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
## Color
+1 -1
View File
@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
## 颜色
+31 -9
View File
@@ -841,11 +841,9 @@ export function createTuiChat(
palette,
overlayManager,
// Optional and independently mounted: read at each use so config row order
// cannot decide whether /resume works.
// The TUI and query provider are sibling Loader fibers. During a command
// callback Cordis may transiently mark the provider non-ACTIVE even though
// its init completed and its disposal is ordered after this consumer.
sessionQuery: () => ctx.get('sessionQuery', false),
// cannot decide whether /resume works. Strict lookup excludes a closing or
// closed provider rather than dispatching into a stale SQLite handle.
sessionQuery: () => ctx.get('sessionQuery'),
ui,
editor,
appendNotice,
@@ -1654,9 +1652,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
if (existing !== undefined) start(existing)
}
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
/**
* Dispose the whole application before process exit, with a bounded fallback.
* @param ctx - The TUI plugin context whose root owns sibling resources.
* @param code - Process status to report.
* @param exit - Exit boundary, replaceable by tests.
*/
export function disposeRootAndExit(
ctx: Context,
code: number,
exit: (status: number) => void = (status) => { process.exit(status) },
): void {
let exited = false
const exitOnce = (): void => {
if (exited) return
exited = true
exit(code)
}
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
void ctx.root.fiber.dispose().then(
() => { clearTimeout(timeout); exitOnce() },
() => { clearTimeout(timeout); exitOnce() },
)
}
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
and the tui-agent PTY smoke covers the real entry */
and apps/cli PTY smokes cover the real entry */
export function apply(ctx: Context, config: Config): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
@@ -1676,9 +1700,7 @@ export function apply(ctx: Context, config: Config): void {
initialSkill === undefined ? {} : { initialSkill },
), {
terminal: new ProcessTerminal(),
exit: (code) => {
void ctx.fiber.dispose().finally(() => { process.exit(code) })
},
exit: (code) => { disposeRootAndExit(ctx, code) },
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
})
+78
View File
@@ -29,6 +29,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
disposeRootAndExit,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
@@ -502,6 +503,30 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('treats a closed session-query provider as unavailable', async () => {
let queryCtx: Context | undefined
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
await ctx.plugin({
apply(child: Context) {
queryCtx = child
child.provide('sessionQuery', {
listSessions: () => Promise.reject(new Error('closed database must not be called')),
} as never)
},
})
},
})
await queryCtx!.fiber.dispose()
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session query is not mounted')
expect(result.terminal.output).not.toContain('closed database')
await dispose(result)
})
it('keeps persisted query records readable without a persistence service', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
@@ -4962,6 +4987,59 @@ describe('TUI extension service', () => {
})
})
describe('application exit', () => {
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
const rootDispose = vi.fn(() => Promise.resolve())
const childDispose = vi.fn(() => Promise.resolve())
const ctx = {
root: { fiber: { dispose: rootDispose } },
fiber: { dispose: childDispose },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 7, exit)
await Promise.resolve()
expect(rootDispose).toHaveBeenCalledOnce()
expect(childDispose).not.toHaveBeenCalled()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when root disposal does not settle', async () => {
vi.useFakeTimers()
try {
let settle!: () => void
const disposal = new Promise<void>((resolve) => { settle = resolve })
const ctx = {
root: { fiber: { dispose: () => disposal } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 9, exit)
await vi.advanceTimersByTimeAsync(4_999)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(9)
settle()
await disposal
await Promise.resolve()
expect(exit).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('exits after a rejected root disposal without an unhandled rejection', async () => {
const ctx = {
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 5, exit)
await Promise.resolve()
await Promise.resolve()
expect(exit).toHaveBeenCalledWith(5)
})
})
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()
+3
View File
@@ -365,6 +365,9 @@ importers:
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../packages/bash/tool-bash
'@deepseek-ai/dsh-tool-cordis':
specifier: workspace:^
version: link:../../packages/cordis/tool-cordis
'@deepseek-ai/dsh-tool-fs':
specifier: workspace:^
version: link:../../packages/fs/tool-fs
+10 -22
View File
@@ -1,28 +1,16 @@
/**
* Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay
* includes its base example, selects Code Mode, and adds the worker runtime.
* All require a DeepSeek API key; unsupported arguments fail with usage.
*/
/** Boot the ACP Code Mode overlay. Requires a DeepSeek API key. */
import { spawn } from 'node:child_process'
// Each UI's node invocation matches its base demo script plus the overlay config.
const UIS = new Map([
['tui', [
'--import',
'tsx/esm',
'apps/cli/src/bin.ts',
'--config',
'examples/code-mode/cordis.yml',
]],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'tui'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [tui|acp]')
if (process.argv.length > 2) {
console.error('usage: pnpm run demo:code-mode')
process.exit(2)
}
const child = spawn(process.execPath, args, { stdio: 'inherit' })
const child = spawn(process.execPath, [
'--import',
'tsx',
'packages/examples/acp-demo/src/bin.ts',
'--config',
'examples/acp-agent/code-mode.cordis.yml',
], { stdio: 'inherit' })
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })
+7 -7
View File
@@ -29,6 +29,9 @@ interface PluginReference {
}
const root = resolve(import.meta.dirname, '..')
// These example files are overlays consumed by the built dsh app, so their bare
// specifiers resolve from apps/cli rather than the examples workspace.
const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml'])
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
@@ -109,7 +112,7 @@ function validateExampleResolution(): string[] {
const dependencies = exampleManifest.dependencies ?? {}
const localPackages = localPackageDirectories()
const rootReferences = rootProjectReferences()
const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/'))
const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/') && !appOverlayFiles.has(reference.file))
violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
@@ -129,12 +132,9 @@ function validateExampleResolution(): string[] {
function validateAppResolution(): string[] {
const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
const shipped = new Set([
'apps/cli/config/base.cordis.yml',
'apps/cli/config/tui.cordis.yml',
'apps/cli/config/web.cordis.yml',
])
const references = pluginReferences.filter(reference => shipped.has(reference.file))
const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') })
.map(file => `apps/cli/config/${file}`))
const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
}