refactor(cli)!: one shared base config with per-surface overlays

`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
This commit is contained in:
Turtle
2026-07-29 13:58:21 +08:00
parent e7c0a5b794
commit f290a8b851
182 changed files with 1659 additions and 2400 deletions
@@ -1,6 +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-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a
2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95
# 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: 4b6a41489b3a1031aad6134052273035ea208e30
2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479
@@ -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-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots.
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/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-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.
@@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个
保留的应用角色均有明确归属:
- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。
- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。
- [`@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` 负责各自的分帧协议集成。
@@ -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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md
2026-07-29-shared-base-config-overlays.md: c3c1bd962dd61201958053d1f1c04e05646f78bd
2026-07-29-shared-base-config-overlays.zh.md: dcf7eaafa7f46b8c2fc1a45e627d31770ba3b234
@@ -0,0 +1,53 @@
# Agent Note: One shared base config with per-surface overlays
Status: implemented
English | [中文](2026-07-29-shared-base-config-overlays.zh.md)
## Problem
`dsh` shipped two full config trees that were 43 rows the same. `apps/cli/cordis.yml` composed the web surface as 74 flat rows, while the TUI booted `examples/tui-agent/cordis.yml`, whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins and re-declared their configuration as its own twenty-key pass-through `Config`.
Neither file was what its location claimed. `examples/tui-agent` was not an example: `apps/cli/src/tui.ts` hardcoded it as the product's default config, and it owned the TUI PTY smoke, the eight terminal snapshot scenarios, and the PTY harness the `cordis-agent` leaf imported. `dsh-tui-demo` was not a demo either — it was the application, mounted by the shipped binary from `packages/examples/`.
The duplication was the load-bearing problem. Of the 43 shared rows, 38 were byte-identical and 5 differed for a defensible per-surface reason, so every capability change had to be made twice and could silently drift. The bundle also inverted a default: `composeTuiApp` read `config.goals ?? {}`, so the shipped TUI mounted goals, `tool-goal`, `goal-session`, and `/goal` although no config key requested them.
## Decision
One shared base, one overlay per surface, composed as sibling patch lists.
`apps/cli/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/tui.cordis.yml` and `apps/cli/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows.
Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches.
`--config <path>` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace <path>` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent.
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.
## Alternatives considered
**Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it.
**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op.
**Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off.
**Keep the per-surface rows in the base and let overlays patch them.** Adopted only for the five rows that must exist in both trees, because a patch cannot create a row. Their base entries carry the plugin name and the config both surfaces share; each overlay states the rest.
## Consequences
An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`.
A patch whose `id` matches no row stays a Loader warning rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot.
`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row.
## Verification
Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML: the TUI settles 55 entries and web 75, both with zero unloaded or unsettled rows, and web's `httpServer` up. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`.
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`).
Flattening surfaced three latent defects, each fixed here: the TUI captured the optional `sessionQuery` service once at construction and so could permanently disable `/resume` when it won the mount race; the shipped session-store root silently reverted to a project-local `./.sessions`; and `--config-replace` was dropped by the resume handoff.
@@ -0,0 +1,53 @@
# Agent Note: 一份共享 base 配置加各 surface 的 overlay
Status: implemented
[English](2026-07-29-shared-base-config-overlays.md) | 中文
## 问题
`dsh` 交付了两棵完整的配置树,其中 43 个配置项完全相同。`apps/cli/cordis.yml` 以 74 个平铺配置项组合 web surface,而 TUI 启动的是 `examples/tui-agent/cordis.yml`——其中单独一行 `@deepseek-ai/dsh-tui-demo` 挂载了十二个插件,并把它们的配置重新声明为自己那份二十个键、仅作透传的 `Config`
这两份文件都名不副实。`examples/tui-agent` 并不是示例:`apps/cli/src/tui.ts` 把它硬编码为产品的默认配置;它还拥有 TUI 的 PTY 冒烟测试、八个终端快照场景,以及被 `cordis-agent` 叶节点 import 的 PTY harness。`dsh-tui-demo` 也不是 demo——它就是应用本身,由交付的二进制从 `packages/examples/` 中挂载。
真正决定性的问题是重复。43 个共享配置项中,38 个逐字节相同,5 个因各 surface 的正当理由而不同;因此每次能力改动都必须改两处,而且可能无声漂移。该组合包还反转了一个默认值:`composeTuiApp` 读取 `config.goals ?? {}`,于是交付的 TUI 挂载了 goals、`tool-goal``goal-session``/goal`——尽管没有任何配置键要求它们。
## 决策
一份共享 base,每个 surface 一份 overlay,以平级 patch 列表的形式组合。
`apps/cli/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/tui.cordis.yml``apps/cli/web.cordis.yml`**patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。
优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。
`--config <path>` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace <path>` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。
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。
## 备选方案
**保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。
**把 overlay 嵌套成 include`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。
**把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。
**把因 surface 而异的配置项留在 base 中,由 overlay 去 patch。** 仅对必须同时存在于两棵树中的那五个配置项采用,因为 patch 无法创建配置项。它们在 base 中的条目携带插件名与两个 surface 共享的配置,其余部分由各 overlay 声明。
## 影响
指名 `@deepseek-ai/dsh-tui-demo`、或 patch `tui-agent` 配置项的 overlay 与 `--config` 树将不再可解析。overlay 现在要 patch 拥有对应键的那一行:模型路由在 `agent-loop`,人设在 `system-prompt`,呈现设置在 `tui`
若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。
`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry``AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。
## 验证
组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 `httpServer` 已启动。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`
全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。
平铺过程暴露出三处潜伏缺陷,均在此一并修复:TUI 曾在构造时一次性捕获可选的 `sessionQuery` 服务,因此在挂载竞争中胜出时会永久禁用 `/resume`;交付的会话存储根目录曾静默退回项目本地的 `./.sessions``--config-replace` 曾在 resume 交接中被丢弃。