diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index fa9c0d9a21..649c2f1d87 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/README.i18n.yaml
@@ -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
-README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668
-README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511
+README.md: d2f6d216b151673d818337c67a78dbe908786c8b
+README.zh.md: 4c7f785ba7478f35cade742409d87746ddcdf8ec
diff --git a/.agents/notes/README.md b/.agents/notes/README.md
index 4db9f16956..d2f6d216b1 100644
--- a/.agents/notes/README.md
+++ b/.agents/notes/README.md
@@ -37,7 +37,11 @@ The `architecture` / `process` line: **architecture** is about the source we shi
Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
-Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
+Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
+
+An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale.
+
+A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling.
## The file format
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index 60ec5421e7..4c7f785ba7 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -39,7 +39,11 @@
每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
-更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
+更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
+
+被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。
+
+只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证契约。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。
diff --git a/.agents/notes/implemented/AGENTS.md b/.agents/notes/implemented/AGENTS.md
index 5fb3fde8e2..c34e1a49b8 100644
--- a/.agents/notes/implemented/AGENTS.md
+++ b/.agents/notes/implemented/AGENTS.md
@@ -8,4 +8,4 @@ Keep paths, symbols, defaults, and mechanisms current in the same change that al
### This is not a license to rewrite the *decision*
-Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; see the [Agent Note contract](../README.md).
+Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md).
diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md
deleted file mode 100644
index 947d53555d..0000000000
--- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Agent Note: Custom typed tool-schema DSL instead of schemastery
-
-Status: implemented
-
-English | [中文](2026-06-11-custom-schema-dsl.zh.md)
-
-## Problem
-
-Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array.
-
-## Decision
-
-This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools.
-
-## Alternatives considered
-
-**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
-
-## Consequences
-
-- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
-- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules.
-- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md
deleted file mode 100644
index 26ebfe2fb1..0000000000
--- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery
-
-Status: implemented
-
-[English](2026-06-11-custom-schema-dsl.md) | 中文
-
-## 问题
-
-工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
-
-## 决策
-
-该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true`;`InferArgs` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。
-
-## 曾考虑的替代方案
-
-**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。
-
-## 后果
-
-- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。
-- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。
-- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。
diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
index b289d47edd..d8dae837fe 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
@@ -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
-2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65
-2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b
+2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279
+2026-06-18-shared-persistence-write-coordinator.zh.md: 40a7144038ac0db4ca6cac651c0a3cef5de4afa9
diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
index ea9c4fb74f..4632351a6f 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
@@ -23,7 +23,7 @@ The coordinator retires a session from `session/disposed`: it waits for the cont
Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage:
- `name` — backend label for the dispose-failure `AggregateError`.
-- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
+- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook).
- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`).
- `list()` — list all stored metadata.
diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
index 3b4dd7b762..40a7144038 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
@@ -23,7 +23,7 @@ Status: implemented
五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界:
- `name`——后端标签,用于 dispose 失败时的 `AggregateError`。
-- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有 cwd bucket;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
+- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。
- `list()`——列出所有已存储的元数据。
diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml
index 7db4f97aa4..03a271a9c3 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml
@@ -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
-2026-06-20-package-hierarchy.md: 7cd07ff90225872f2a17b9a678e52fcee416b09a
-2026-06-20-package-hierarchy.zh.md: 9ef89bd56144b39bb3240a22a2bb1e9216e24115
+2026-06-20-package-hierarchy.md: 4e05e3487483ab8d710959c1888ec1f5c3b37432
+2026-06-20-package-hierarchy.zh.md: f57704ad082c4961aa48056af1b4b279d2f2c055
diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md
index 7cd07ff902..4e05e34874 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-06-20-package-hierarchy.zh.md)
-The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here.
+The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) deletes the original `support/ui-stdio` surface instead of relocating it, and the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here.
## Problem
diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md
index 9ef89bd561..f57704ad08 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-06-20-package-hierarchy.md) | 中文
-后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。
+[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)直接删除最初的 `support/ui-stdio` 接口,而不是将其迁移;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。
## 问题
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
new file mode 100644
index 0000000000..1c6f4e06ac
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5
+2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
index 28de5eb97c..22a56dc6d6 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md)
+
## Problem
`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank.
@@ -42,7 +44,7 @@ The agent loop keeps `RequestError` as that exact error object and passes `LlmFa
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
-The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
+The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, and `EMPTY_RESPONSE` for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
### Put retry policy on the existing failed-step seam
@@ -62,7 +64,7 @@ interface Config {
}
```
-The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
+The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered.
@@ -124,7 +126,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff.
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery.
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance.
-- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion.
+- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
new file mode 100644
index 0000000000..09ebce376a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
@@ -0,0 +1,148 @@
+# Agent Note: LLM(大语言模型)暂时性请求失败的有界恢复
+
+Status: implemented
+
+[English](2026-06-21-bounded-llm-request-recovery.md) | 中文
+
+## 问题
+
+`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。
+
+该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
+
+此前的边界还留有三个较窄的缺口。
+
+- 提供方失败只保留消息,通常还会保留一个 code。HTTP 状态、重试延迟和提供方请求 id 会被丢弃,或者只能通过提供方专用错误对象恢复,因此通用恢复机制如果不解析文本,便无法作出决策或解释决策。
+- 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。
+- 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。
+
+本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。
+
+## 决策
+
+### 保留失败事实,不嵌入策略
+
+`@deepseek-ai/dsh-llm` 导出唯一的可 JSON 序列化 `LlmFailure` 载荷:
+
+```ts ignore-check
+type ProviderRequestId = Branded<'ProviderRequestId'>
+
+interface LlmFailure {
+ message: string
+ code: string
+ status?: number
+ providerRetryAfterMs?: number
+ requestId?: ProviderRequestId
+}
+```
+
+`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。
+
+`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。
+
+agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。
+
+适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。
+
+共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
+
+### 将重试策略放在现有失败步骤 seam 上
+
+`@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。
+
+`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。
+
+该插件在加载时解析并验证以下部署配置:
+
+```ts ignore-check
+interface Config {
+ maxTransientRetries?: number
+ initialDelayMs?: number
+ maxDelayMs?: number
+ jitterRatio?: number
+ retryableCodes?: string[]
+}
+```
+
+默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。
+
+对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。
+
+插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。
+
+休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。
+
+对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。
+
+agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。
+
+### 由单一层负责可见的尝试
+
+适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。
+
+`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。
+
+### 在能够终止停滞流的位置施加边界
+
+每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。
+
+`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。
+
+边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。
+
+### 在现有日志中分隔尝试
+
+一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。
+
+如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。
+
+## 不在范围内
+
+- 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。
+- 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。
+- 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。
+- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。
+- 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。
+
+## 考虑过的替代方案
+
+- **在 `llm/stream` 或提供方 SDK 内部重试**:拒绝采用,因为原始流一旦发出分片便没有持久尝试边界,隐藏的 SDK 重试会成倍放大预算,而且两条路径都无法一致地记录每次失败尝试。
+- **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。
+- **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。
+- **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。
+- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。
+- **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。
+- **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。
+
+## 验证
+
+- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。
+- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。
+- DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。
+- Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。
+- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。
+- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。
+- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。
+- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。
+- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。
+- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。
+- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
+- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。
+- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
+
+## 后果
+
+- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。
+- 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。
+- 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。
+- 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。
+- 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。
+- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。
+
+## 相关资料
+
+- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。
+- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。
+- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。
+- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复 seam 与有界溢出重试。
+- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。
diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml
index 9871c7a528..d2ac37af62 100644
--- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml
@@ -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
-2026-07-02-tool-render-intent-union.md: 6cfd8921decbe16343f963574edd52173c2f8698
-2026-07-02-tool-render-intent-union.zh.md: d0414c5f15995192df898e968d054933f82d2ab4
+2026-07-02-tool-render-intent-union.md: 84423e9000526848a111591c1bb2ab92067bbe50
+2026-07-02-tool-render-intent-union.zh.md: 43873c622fc8483b4a7033d17b4b4fab1342a56b
diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md
index 6cfd8921de..84423e9000 100644
--- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md
+++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md
@@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte.
+The terminal intent is display-only. The harness still executes the command through its bash service, preserving sandboxing, environment scrubbing, task ownership, and per-session cwd; a UI projects the completed call and never becomes a second execution backend.
+
### Purity preserved
`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`.
@@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
## Alternatives considered
- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
+- **Let a UI execute terminal intents** — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution.
- **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch.
- **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.
diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md
index d0414c5f15..43873c622f 100644
--- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md
@@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。
+terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命令,从而保留沙箱、环境清理、任务归属和每会话 cwd;UI 只呈现已完成的调用,绝不会成为第二个执行后端。
+
### 纯函数性保持不变
`presentCall`/`presentResult` 仍然是 `args`(`presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。
@@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
## 曾考虑的替代方案
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。
+- **让 UI 执行 terminal 意图**:否决。这样会绕过 harness 的 bash 策略与归属契约,并把命令执行分裂到不同后端。terminal 卡片描述的是 harness 拥有的执行,绝不授权客户端侧执行。
- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。
- **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md
deleted file mode 100644
index 932b6ddbf4..0000000000
--- a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Agent Note: Windows write-permission semantics — inherited DACLs, not mode bits
-
-Status: implemented
-
-The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
-
-## Problem
-
-`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions.
-
-Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding Agent Note.
-
-## Decision
-
-New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
-
-Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist.
-
-## Alternatives considered
-
-**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy.
-
-**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile.
-
-**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior.
-
-## Consequences
-
-POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists.
-
-Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter.
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml
new file mode 100644
index 0000000000..4322906bb9
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775
+2026-07-05-windows-jsonl-durable-publish.zh.md: 8dc77a0ab1b9273cf3f6ecb26916c7861ee81ec4
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md
index 60c3b9b627..38c4adc7a4 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md
+++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-05-windows-jsonl-durable-publish.zh.md)
+
## Problem
`dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized.
@@ -12,9 +14,9 @@ Windows has atomic namespace operations, but Node does not expose a POSIX-equiva
The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols.
-POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link.
+POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link.
-Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
+Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
## Alternatives considered
@@ -28,6 +30,6 @@ Windows creates missing directories through a durable staging publish: create a
The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes.
-Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally.
+Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, maximum-length target components remain materializable, temp logs are fsync'd before publication, and the resulting log loads normally.
Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles.
diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md
new file mode 100644
index 0000000000..8dc77a0ab1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Windows 原生持久 JSONL 发布
+
+Status: implemented
+
+[English](2026-07-05-windows-jsonl-durable-publish.md) | 中文
+
+## 问题
+
+`dsh-session-persistence-jsonl` 在首次追加时延迟发布会话日志。POSIX 协议会写入临时文件,对其执行 fsync,将其链接至最终名称,对父目录执行 fsync,然后移除临时链接。对父目录执行 fsync 是持久性契约的一部分:命名空间变更后发生崩溃时,已经提交的最终名称不能丢失,否则调用方会误以为会话日志已经物化。
+
+Windows 具备原子命名空间操作,但 Node 没有暴露与 POSIX 等价的父目录 fsync 契约。如果把 Windows 目录同步失败视为成功,就会在无提示的情况下削弱持久化后端。因此,Windows 路径需要采用不同的发布原语,而不是在 POSIX 的 `syncDir` 辅助函数中添加条件分支。
+
+## 决策
+
+JSONL 后端会在 `materialize()` 内部、任何命名空间变更之前分流。共享代码计算会话目录、最终日志路径,以及编码后的 header 和初始事件批次;随后 POSIX 与 Windows 分别执行各自的发布协议。
+
+POSIX 保留现有协议:创建根目录、项目目录与会话目录,并对其父目录执行 fsync;写入临时文件并对其执行 fsync;使用 `link()` 发布,确保绝不覆盖已有的最终日志;对会话目录执行 fsync;最后移除多余的临时硬链接。
+
+Windows 通过持久的暂存发布来创建缺失目录:在固定的 `.dsh-mkdir-` 前缀下创建一个随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包(package)会分发原生 loader 和预构建的平台模块。
+
+## 考虑过的替代方案
+
+**忽略 Windows 目录同步失败。** 不予采纳,因为这会在没有强制将已发布的命名空间条目写入稳定存储时,就把首次追加报告为持久化成功。
+
+**使用 `CreateHardLinkW`。** 不予采纳,因为硬链接依赖文件系统、不能发布目录,并且没有提供写穿透选项。
+
+**使用替换或事务型 API。** `ReplaceFileW` 的替换语义与拒绝同一 id 冲突的要求相悖,而新应用设计不应使用 Transactional NTFS。
+
+## 影响
+
+该后端在各平台上维持同一项外部契约:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。
+
+Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 契约属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。
+
+两个平台的追加和修复仍使用普通文件句柄 fsync。追加失败后,系统会关闭仅追加句柄,以读写模式重新打开日志,将文件截断到追加前的大小,并对回滚结果执行 fsync,因为 Windows 不允许在仅追加句柄上调用 `ftruncate`。
diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml
new file mode 100644
index 0000000000..6663606c3c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322
+2026-07-06-tool-result-retention-library.zh.md: 6e824667f17b361efb57b173c44f489da2cab3b3
diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md
index 2962f1006e..5e42660360 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md
+++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-06-tool-result-retention-library.zh.md)
+
## Problem
Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts.
diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md
new file mode 100644
index 0000000000..6e824667f1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md
@@ -0,0 +1,157 @@
+# Agent Note: 工具结果保留库
+
+Status: implemented
+
+[English](2026-07-06-tool-result-retention-library.md) | 中文
+
+## 问题
+
+多个面向模型的工具已经限制其返回的上下文量,但每个工具都拥有不同的局部机制和词汇:bash 保留尾部并提供落盘文件;web search 限制来源列表;web fetch 限制正文内容;`glob`/`grep` 发现工具需要在行内提供第一页,同时为完整结果集保留精确的省略元数据。单一的 `truncate(text)` 辅助函数无法覆盖这些情况:条目型工具需要条目计数,并在原语之外分组;文本型工具则需要字节预算和 UTF-8 安全的首尾裁切。
+
+这些工具需要共享的抽象是**保留**,而不是通用集合。调用方向一个有界对象输入条目或文本分片,稍后取得保留内容与精确的省略元数据。工具专用代码仍负责业务语义:文件分组、行号、退出码、提供方错误状态、落盘文件和面向模型的说明。公共库只负责一个机械问题:「保留了什么,又省略了什么?」
+
+## 决策
+
+`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包(package)需要限制输出时直接导入它。
+
+该库包含两个相互独立的 retainer:
+
+- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。
+- `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。
+
+两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。
+
+```ts ignore-check
+/**
+ * How much content the retainer omitted.
+ *
+ * `unknown` is reserved for callers that omit without a count; the retainers
+ * themselves return `none` or `exact`.
+ */
+type Omitted =
+ | { kind: 'none' }
+ | { kind: 'exact'; count: number }
+ | { kind: 'unknown' }
+
+interface PushDecision {
+ kept: boolean
+ truncated: boolean
+}
+
+/**
+ * Final result for ordered logical units.
+ */
+interface RetainedItems {
+ items: T[]
+ truncated: boolean
+ seen: number
+ kept: number
+ omitted: Omitted
+}
+
+/**
+ * Final result for text streams.
+ *
+ * The returned `text` is safe to send to a formatter; the retainer does not add
+ * tool-specific headers, exit markers, XML tags, or recovery instructions.
+ */
+interface RetainedText {
+ text: string
+ truncated: boolean
+ omittedBytes: Omitted
+}
+```
+
+### 策略
+
+条目保留支持头部窗口。文本保留支持头部、尾部与首尾字节窗口。
+
+```ts ignore-check
+type ItemRetentionStrategy =
+ | {
+ /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
+ kind: 'head'
+ maxItems: number
+ }
+
+type TextRetentionStrategy =
+ | {
+ /** Keep the first `maxBytes` bytes. */
+ kind: 'head'
+ maxBytes: number
+ }
+ | {
+ /** Keep the final `maxBytes` bytes. Requires reading to the end. */
+ kind: 'tail'
+ maxBytes: number
+ }
+ | {
+ /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
+ kind: 'headTail'
+ headBytes: number
+ tailBytes: number
+ }
+```
+
+### 工具映射
+
+`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。
+
+下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。
+
+`glob` 收集完整的排序路径列表后,使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: globMaxResults }`。工具在行内保留第一页,并可以通过落盘 seam 保存完整列表。路径映射、跳过的候选项与 `incomplete` 均位于 retainer 之外。
+
+`grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过落盘 seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。
+
+`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责落盘文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。
+
+`web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。
+
+`web_search` 可以使用 `ItemRetainer`,配置为 `head`。当前提供方通常返回数组,所以这属于事后处理,但仍能统一提示信息。
+
+### 提示
+
+该库公开一个中性的提示结构和一个小型格式化钩子,但面向用户的措辞由工具提供。grep 页脚会提示「缩小 pattern、path 或 include」;web fetch 页脚会提示「获取更具体的 URL 或章节」;bash 则可以指向落盘文件。retainer 无法得知这些恢复操作。
+
+```ts ignore-check
+interface RetentionNotice {
+ scope: string
+ strategy: 'head' | 'tail' | 'headTail'
+ unit: 'items' | 'bytes' | 'chars' | 'lines'
+ limit: number | { head: number; tail: number }
+ kept: number
+ omitted: Omitted
+}
+
+const formatGrepNotice = (notice: RetentionNotice): string =>
+ formatRetentionNotice(
+ notice,
+ ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
+ )
+```
+
+格式化钩子刻意保持精简:工具把 `RetentionNotice` 转换为自己的页脚文本。辅助函数可以统一省略措辞,但不负责恢复指引。
+
+`truncated` 表示 retainer 因预算省略了原本可用的内容,不表示上游结果不完整。工具会为权限失败、跳过的二进制文件、提供方局部失败、不可读候选项、无效 UTF-8,以及其他任何「无法检查」状况保留独立字段。
+
+## 影响
+
+**已交付内容。** `@deepseek-ai/dsh-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。
+
+**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口契约(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。
+
+**该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash 落盘路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。
+
+**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。
+
+## 考虑过的替代方案
+
+**只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。
+
+**使用一个带可插拔回调的通用 `Collector`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。
+
+**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。
+
+**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer 使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。
+
+**在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。
diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
new file mode 100644
index 0000000000..c241ef4ece
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-08-tool-output-spill-files.md: 7c0ca90452645d251559be25108d12883210d00e
+2026-07-08-tool-output-spill-files.zh.md: 917d710eb8650e2797287578edd1b0d62813bbd3
diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
index e2df9a902b..7c0ca90452 100644
--- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
+++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-08-tool-output-spill-files.zh.md)
+
## Problem
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools.
diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
new file mode 100644
index 0000000000..917d710eb8
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
@@ -0,0 +1,195 @@
+# Agent Note: 工具输出落盘策略
+
+Status: implemented
+
+[English](2026-07-08-tool-output-spill-files.md) | 中文
+
+## 问题
+
+工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。
+
+这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行临时实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。
+
+其形态与超时策略设计一致:工具作者声明规范值与 Native renderer,由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。
+
+## 决策
+
+在新的 `packages/spill/` 分组下增加一层轻量落盘存储 seam 和一个默认落盘策略插件:
+
+| 包(package) | 角色 |
+|---|---|
+| `@deepseek-ai/dsh-spill` | 接口:`ctx.spillStore`、词汇类型,不包含存储实现。 |
+| `@deepseek-ai/dsh-spill-local` | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 |
+| `@deepseek-ai/dsh-spill-policy` | 工具结果策略插件:包装分发后的最终文本结果,并以保留预览和落盘定位符替换超大结果。 |
+
+系统不增加专用的面向模型消费方包。消费方是现有 `ctx.tools` 执行流水线:`dsh-spill-policy` 通过 `tools/post-execute` waterfall(瀑布式事件)使用最终工具结果,模型则按照后端随定位符返回的检索提示读取内容。
+
+### 落盘 seam
+
+存储 seam 保持最小化:保存文本,并返回定位符与检索提示。
+
+```ts ignore-check
+interface SpillStore {
+ saveText(input: SaveTextSpill): Promise
+}
+
+interface SpillSource {
+ toolName: string
+ callId: CallId
+ label: string
+}
+
+interface SaveTextSpill {
+ owner: { sessionId: SessionId }
+ source: SpillSource
+ suggestedName: string
+ content: string
+}
+
+type SpillLocator = Branded<'SpillLocator'>
+
+interface SpillRef {
+ locator: SpillLocator
+ bytes: number
+ retrievalHint: string
+}
+```
+
+`SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的落盘定位符,无需复制它们或重新取得所有权;fork 后的新落盘使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。
+
+`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。
+
+### 落盘策略
+
+`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
+
+```ts ignore-check
+interface Config {
+ /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
+ maxInlineBytes?: number
+}
+```
+
+省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
+
+1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
+2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
+3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
+4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
+5. 把模型可见结果替换为保留的首尾预览和落盘引用。
+
+预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二种部署证明有此需求后,未来配置才会公开预览大小。
+
+替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
+
+```text
+
+
+(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
+```
+
+如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。落盘失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
+
+策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
+
+## 示例:web_fetch
+
+`web_fetch` 是首个示例,因为它天然会返回较大的文本结果,而且无需工具专用的落盘代码。该工具本身无需特殊处理:
+
+```ts ignore-check
+ctx.tools.register(defineTool({
+ name: 'web_fetch',
+ output: {
+ schema: WEB_FETCH_RESULT_SCHEMA,
+ render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
+ },
+ async execute(args, exec) {
+ const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
+ return result
+ },
+}))
+```
+
+配置 `dsh-spill-policy` 后,格式化后的大型 fetch 结果会自动保留并落盘。部署通过把提供方资源上限设得高于策略上限来展示此行为:
+
+```yaml
+- id: web-fetch-local
+ name: '@deepseek-ai/dsh-web-fetch-local'
+ config:
+ maxBodyChars: 500000
+
+- id: spill-local
+ name: '@deepseek-ai/dsh-spill-local'
+
+- id: spill-policy
+ name: '@deepseek-ai/dsh-spill-policy'
+ config:
+ maxInlineBytes: 50000
+```
+
+这项分离很重要。`web-fetch-local` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,落盘文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
+
+## 与保留和提前落盘的关系
+
+保留与落盘存储相互独立:
+
+- `@deepseek-ai/dsh-retention` 负责预览机制(`TextRetainer`、`ItemRetainer` 和省略元数据)。
+- `@deepseek-ai/dsh-spill` 负责保存最终文本,并返回定位符与检索提示。
+- `@deepseek-ai/dsh-spill-policy` 在工具流水线中应用默认的最终结果策略,将前两者组合起来。
+
+最终结果策略不能取代由工具负责的提前落盘。部分有用内容并不存在于最终 `ToolExecutionResult.content` 中:
+
+- `bash` 的最终输出已经是尾部内容加临时落盘路径;完整的 stdout/stderr 流位于执行器文件中。
+- `subagent` 的最终输出是子 agent(智能体)的最终回答,而不是子 agent 的执行轨迹。
+- 未来的工具可能生成从未出现在最终 `ToolExecutionResult.content` 中的运行时产物。
+
+这些场景可以在后续工作中直接使用 `ctx.spillStore`,不属于首个示例的范围。
+
+## 非目标
+
+- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。
+- v1 不增加逐工具的保留配置。
+- 不增加面向模型的超时/截断参数。
+- 不把 `read` 输出迁移到落盘文件。
+- 不取代 `web-fetch-local.maxBodyChars` 等提供方/资源上限。
+- 第一版不统一 bash 临时文件,也不采集 subagent 执行轨迹。
+
+## 延后事项
+
+- 用于现有执行器落盘文件的 `saveFile()`/`linkOrCopy`,这是统一 bash 行为所必需的。
+- 由工具负责的 subagent 执行轨迹落盘(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。
+- 如果内置的 `read` 跳过规则不足,再增加逐工具退出或逐工具策略声明。
+- 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。
+- 旧落盘文件的清理和保留策略,很可能与会话清理绑定。
+
+## 测试
+
+- `dsh-spill` 单元测试锁定 seam 契约:注册为 `ctx.spillStore`、每个上下文只允许一种实现,并在 dispose(资源释放)时释放。
+- `dsh-spill-local` 单元测试覆盖 `saveText`、`encodeSegment` 清理(分隔符/波浪号/完整路径段的点/空值)、会话哈希目录、仅所有者权限、每次保存生成不同路径、配置根目录/私有根目录,以及存储失败时的拒绝。
+- `dsh-spill-policy` 单元测试通过 `ctx.tools.execute` 驱动真实工具:禁用模式下无操作、替换超大文本、小结果/非文本结果保持不变、跳过 `read`、尽力回退(保存失败/无后端/无所有者),以及下游组合(限制已替换结果、保留 `additionalContexts`)。
+- `dsh-tool-web` 集成测试驱动 `web_fetch`,其实际执行路径经过 `ctx.tools.execute`,并使用真实的 `spill-local` 后端与策略;测试证明只有刻意加入的落盘提示会改变模型可见文本,而落盘文件保存完整的格式化结果。
+- `tui-agent` 示例加载 `spill-local` 与 `spill-policy`,因此其无密钥 Loader/PTY 冒烟测试会执行真实加载路径(namespace-plugin 导出形态与 `inject`)。
+
+## 影响
+
+默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。
+
+本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。
+
+本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。
+
+**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` 落盘提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` 落盘需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
+
+如果策略开始负责工具专用语义,就会膨胀得过大。它必须保持狭窄:只处理纯文本最终结果。由工具负责的提前落盘仍留作未来工作。
+
+## 考虑过的替代方案
+
+**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
+
+**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前落盘合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
+
+**使用 `ctx.fs.writeText` 或面向模型的 `write` 工具。** 不予采纳:工作区文件系统写入带有项目文件语义、写入/编辑策略、观察状态和面向用户的副作用。落盘文件是运行时产物,不是由模型编写的工作区改动。现有 `read` 工具之后可以检查它们,但创建操作属于运行时落盘 seam。
+
+**让 `web-fetch-local` 不受限地抓取,只依靠 spill-policy。** 不予采纳:spill-policy 在最终工具结果已经存在之后才运行,无法保护网络、内存或解码资源。提供方资源上限仍然必须存在。
+
+**把保留合并进落盘机制。** 不予采纳:保留与落盘职责不同。`TextRetainer`/`ItemRetainer` 决定保留哪部分预览、又省略了什么;落盘存储只负责保存策略要求的最终文本。
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
index 16852c1004..19a6b628c3 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
@@ -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
-2026-07-20-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9
-2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896
+2026-07-20-unified-json-value-schema-dsl.md: 5de3523eab15a91ea32dc09e2e239146fadea6f1
+2026-07-20-unified-json-value-schema-dsl.zh.md: 321136c31a6aa6c0268150fcde2d97dcdbb0ac58
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
index 09945c413f..5de3523eab 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
@@ -21,6 +21,7 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
## Alternatives considered
- **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary.
+- **Use Schemastery for tool parameters:** rejected because Schemastery targets validation and transformation through Standard Schema rather than JSON Schema generation. It would add an adapter layer without producing the model-facing wire schema or the shared output vocabulary.
- **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest.
- **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default.
- **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values.
@@ -32,4 +33,5 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth.
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
+- Per-property `required: true` remains the tool-author contract, and type-level regression coverage pins required keys as non-optional after the original inference path exposed an optionality bug.
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays.
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
index 00a7a19961..321136c31a 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
@@ -21,6 +21,7 @@ Status: implemented
## 备选方案
- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。
+- **使用 Schemastery 处理工具参数:**不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。
- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。
- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。
- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。
@@ -32,4 +33,5 @@ Status: implemented
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
+- 每个属性的 `required: true` 仍是工具作者契约;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
index 0ed8f5d7a4..eacbd89847 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
@@ -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
-2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd
-2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617
+2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
+2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
index 65b4ebb475..617524475f 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
@@ -42,7 +42,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
| Share | Type | Source of truth | Contents |
|---|---|---|---|
-| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
+| runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
| child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
| store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
@@ -84,7 +84,7 @@ An inject factory takes what its declarations earn it — `sessionId` for sessio
### Data-boundary discipline
-Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
+Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
### Tree context and the renderer seam
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
index 4c55171ca0..52edea30ac 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
@@ -42,7 +42,7 @@ ctx.slots.register({
| 份额 | 类型 | 真源 | 内容 |
|---|---|---|---|
-| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
+| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
| store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
@@ -84,7 +84,7 @@ inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,
### 数据界线纪律
-hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
+hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
### 树上语境与渲染器安装缝
diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml
index 2a27e6432f..7b83a5dc52 100644
--- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml
@@ -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
-2026-07-23-unified-session-query-service.md: 0a466e1c36ff1796c858666b0eb36bbd0f480bb0
-2026-07-23-unified-session-query-service.zh.md: 448122b8e6951058b9f633cd56112b0391e1912e
+2026-07-23-unified-session-query-service.md: 676a42017ca42f9e649f6529f84787e7162faac0
+2026-07-23-unified-session-query-service.zh.md: d4449a415840d61cbb10f88def1062a13e556749
diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md
index 0a466e1c36..676a42017c 100644
--- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md
+++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md
@@ -16,6 +16,8 @@ The interface package already owns the shared record, filter, trace, search-requ
`SessionQuerySqlite` extends that service and is the sole concrete backend. One mounted instance therefore exposes every operation through `ctx.sessionQuery`; its inherited exact operations use the shared corpus implementation, while its SQLite-owned lifecycle observes sources, reconciles the derived FTS index, ranks matches, and owns cursor generations. The interface package has no standalone concrete plugin, search-provider registry, or second context key.
+SQLite reconciliation is one quiescent serialized state machine. It passes the caller's exact abort signal into durable snapshot listing and inspection, awaits each started backend operation itself, and checks cancellation after every await and before starting the next source or index operation. Cancellation therefore cannot release the serializer while an ignored or cooperative backend call is still cleaning up, and it cannot start a subsequent listing, inspection, reconciliation, or query after the signal is observed.
+
Backend configuration includes the inherited `readWindowMax` setting alongside its own index path, journal mode, page limits, and snippet limit. First-party apps that need session queries mount the SQLite backend and place its disposable index beside their configured persistence root.
This service topology supersedes the separate-key portion of the [exact query decision](../feature/2026-07-10-session-query-service.md) and [SQLite search decision](../feature/2026-07-10-sqlite-session-query-provider.md); their corpus, query, tokenizer, reconciliation, and safety decisions remain in force.
@@ -32,4 +34,6 @@ Consumers inject one service and can combine exact and full-text operations with
The unified object deliberately retains two internal observation strategies: exact operations read authoritative live/persisted sources per call, while full-text operations reconcile a disposable index. Sharing the context key does not make the derived index authoritative or couple exact-read availability to an FTS query.
+Queued cancellation remains prompt. Cancellation during active asynchronous source observation waits for that started operation to settle, which makes rejection a quiescence boundary and preserves single-file execution for a following search. Synchronous SQLite statements remain non-preemptible and are bracketed by signal checks.
+
Unit coverage pins inherited and abstract behavior on one key, SQLite coverage exercises both operation families on the concrete backend, and the real Loader path verifies that one exported plugin registers the combined service.
diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md
index 448122b8e6..d4449a4158 100644
--- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md
@@ -16,6 +16,8 @@ Status: implemented
`SessionQuerySqlite` 扩展该服务,并且是唯一的具体后端。因此,一个挂载实例便可通过 `ctx.sessionQuery` 暴露全部操作;其继承的精确操作使用共享的语料库实现,而由 SQLite 管理的生命周期负责观察数据源、对齐派生 FTS 索引、对匹配项排序并管理游标代际。接口包不提供独立的具体插件、搜索提供方注册表或第二个上下文键。
+SQLite 的对齐过程是一个具备静止性保证的串行状态机。它将调用方的原始中止信号传给持久化快照列表与检查操作,直接等待每个已经启动的后端操作,并在每次等待后以及启动下一个数据源或索引操作前检查是否已取消。因此,即使后端忽略取消或正在配合清理,串行器也不会提前释放;观察到中止信号后,也不会再启动后续的列表、检查、对齐或查询操作。
+
后端配置除了自身的索引路径、日志模式、分页限制与文本片段长度上限外,还包含继承的 `readWindowMax` 设置。需要会话查询的第一方应用挂载 SQLite 后端,并将其可丢弃索引放在已配置的持久化根目录旁。
这一服务拓扑取代了[精确查询决策](../feature/2026-07-10-session-query-service.md)和 [SQLite 搜索决策](../feature/2026-07-10-sqlite-session-query-provider.md)中关于分离上下文键的部分;其中关于语料库、查询、分词器、对齐与安全性的决策仍然有效。
@@ -32,4 +34,6 @@ Status: implemented
统一后的对象有意保留两种内部观察策略:精确操作在每次调用时读取权威的实时源或持久化源,全文操作则使可丢弃索引与数据源对齐。共用上下文键不会让派生索引成为权威来源,也不会使精确读取的可用性依赖 FTS 查询。
+排队阶段的取消仍会及时生效。在异步数据源观察已经开始后取消时,调用方会等待该操作完成清理后才收到拒绝;因此拒绝本身构成静止边界,并保证后续搜索仍按单一串行流程执行。同步 SQLite 语句无法在执行中被抢占,服务会在其前后检查中止信号。
+
单元测试在同一个键上同时固定继承实现与抽象方法的契约,SQLite 测试在具体后端上覆盖两类操作,真实 Loader 路径则验证单个导出的插件能够注册组合后的服务。
diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml
new file mode 100644
index 0000000000..7bdc4d2825
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-dsh-commander-argument-adapter.md: c1124f67a2c5d9fbba1e04c896a1021c370befc9
+2026-07-24-dsh-commander-argument-adapter.zh.md: be96c354a7dea53446f3c2e35f0e4967265596f5
diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md
new file mode 100644
index 0000000000..c1124f67a2
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md
@@ -0,0 +1,53 @@
+# Agent Note: Parse `dsh` argv through one Commander adapter
+
+Status: implemented
+
+English | [中文](2026-07-24-dsh-commander-argument-adapter.zh.md)
+
+## Problem
+
+The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that did not compose and gave no `--help`/`--version`. `bin.ts` dispatched by raw inspection — `argv[0] === 'web'`, then `argv.includes('-p') || argv.includes('--prompt')`, else TUI — which is positional-blind: a prompt flag or a config path in the wrong position could misroute the mode, and `argv.includes('-p')` could not tell a real flag from an incidental token. `headless.ts` and `web.ts` each ran their own `node:util` `parseArgs` with inline host/port validation, and `dsh-app-boot` carried `parseResumeArg`, a ~30-line bespoke scanner reimplementing flag/`=`-form/value/repeat handling for `--resume`. Usage was a single hardcoded `usage: dsh -p "task"` line; there was no version flag and no rendered help.
+
+## Decision
+
+Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`.
+
+`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev, workspaceRoot)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). Commander parses the default-surface options on either side of the `web` token into `program.opts()`; since `web` shares none of them, the `web` action rejects a leaked `--config`/`-p`/`--resume` (`dsh web -p x`, `dsh --config c.yml web`) rather than silently serving and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch, and `--workspace-root ` is a plain pass-through to `AppCLIEntry` (the parent directory for name-created workspaces). A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`.
+
+`dsh` takes no positional argument. `--config ` names an alternate cordis tree to boot instead of the shipped default; it exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`.
+
+CLI parsing lives entirely in `apps/cli`. `dsh-app-boot` holds the boot/env/config/personal-overlay helpers and no argv scanner.
+
+## Session resume through the boot context
+
+`dsh --resume ` is the one way to resume a persisted session, with no environment variable. `runTui` provides the parsed id on the boot context through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a `dsh-app-boot` export, value `'resumeSessionId'`) — and the shipped tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv from the parsed values as `dsh --resume= [--config ]`.
+
+## One terminal front door: `dsh`
+
+`dsh` is the only terminal entry point; the `dsh-tui-demo` package ships the TUI app bundle plugin the shipped config mounts, and no bin of its own. `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes launch through `apps/cli/src/bin.ts` with `--config `. `dsh`'s TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) is pinned by `apps/cli/tests/built-bin.e2e.ts`, which runs the built `lib/bin.js` under plain Node with piped stdio (`apps/cli/tests` is in the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their own bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide.
+
+## Package topology
+
+The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin.
+
+## Alternatives considered
+
+**Keep `node:util` `parseArgs` and only unify the dispatch** — rejected: `parseArgs` has no subcommand model, no rendered help, and no version flag, so `web` routing and `--help`/`--version` would stay hand-rolled. The repo already chose Commander for its other CLIs; a second parser idiom for `dsh` alone is the fragmentation this change removes.
+
+**Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end.
+
+**A bare `dsh ` positional for the alternate tree** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional). A positional would force `web` into a reserved-first-token dispatch to a separate parser and a hand-maintained `web` line in `--help`. Only the demo/test sites ever need to name an alternate tree, so a `--config` flag serves them while leaving the default surface positional-free — `web` is then a normal subcommand in one program with native `--help`.
+
+**Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern.
+
+**Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`.
+
+**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config ` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point.
+
+## Testing
+
+`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), the exit-code behavior for the adapter's fail-loud checks (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional), and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
+
+## Consequences
+
+`dsh` has rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing does not depend on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) sitting on the CLI's front door. `dsh-app-boot` owns no CLI-parsing surface; a consumer needing `--resume`-style parsing composes Commander. Session resume rides the boot context rather than an environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is a plugin bundle a config mounts.
diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md
new file mode 100644
index 0000000000..be96c354a7
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md
@@ -0,0 +1,53 @@
+# Agent Note: 通过单个 Commander 适配器解析 `dsh` 的 argv
+
+Status: implemented
+
+[English](2026-07-24-dsh-commander-argument-adapter.md) | 中文
+
+## 问题
+
+`dsh` 的 CLI(命令行界面)入口(`apps/cli`)以三种手写方式解析 argv,这些方式无法组合,也不提供 `--help`/`--version`。`bin.ts` 通过原始检查进行分发:先判断 `argv[0] === 'web'`,再判断 `argv.includes('-p') || argv.includes('--prompt')`,否则走 TUI。这种方式对位置不敏感:位置错误的 prompt 标志或配置路径可能把模式路由错,而 `argv.includes('-p')` 无法区分真正的标志和偶然出现的 token。`headless.ts` 和 `web.ts` 各自运行自己的 `node:util` `parseArgs`,并内联校验 host/port,而 `dsh-app-boot` 携带 `parseResumeArg`——一个约 30 行的定制扫描器,为 `--resume` 重新实现了标志、`=` 形式、取值和重复的处理。用法说明只有一行硬编码的 `usage: dsh -p "task"`;既没有版本标志,也没有渲染出的帮助信息。
+
+## 决策
+
+argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。
+
+`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev, workspaceRoot)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。Commander 会将 `web` token 前后的默认接口选项都解析进 `program.opts()`;由于 `web` 不与默认接口共用任何选项,`web` 子命令的处理函数会拒绝误入的 `--config`/`-p`/`--resume`(`dsh web -p x`、`dsh --config c.yml web`),而不是静默启动服务并丢弃这些选项。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视,`--workspace-root ` 则是直接透传给 `AppCLIEntry` 的选项(按名称创建 workspace 时使用的父目录)。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。
+
+`dsh` 不接受位置参数。`--config ` 指定一份替代 Cordis 配置树,系统启动该配置树而不是随产品提供的默认配置树;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。
+
+CLI 解析完全位于 `apps/cli` 中。`dsh-app-boot` 提供启动、环境变量、配置和个人覆盖辅助函数,不包含 argv 扫描器。
+
+## 通过启动上下文恢复会话
+
+`dsh --resume ` 是恢复持久化会话的唯一方式,无需环境变量。`runTui` 通过 `boot` 的 `prepare(ctx)` 钩子,在启动上下文中提供已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的一项导出,值为 `'resumeSessionId'`);随产品提供的 tui-agent/cordis 配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)根据已解析的值将重新执行时的 argv 构造成 `dsh --resume= [--config ]`。
+
+## 唯一的终端入口:`dsh`
+
+`dsh` 是唯一的终端入口;`dsh-tui-demo` 包(package)提供 TUI 应用组合插件,随产品提供的配置会挂载该插件,而该包不提供自己的 bin。`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的无密钥 PTY 冒烟测试都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `。`dsh` 的 TTY 守卫会在启动前拒绝标准输入输出接入管道的调用,并提示自动化场景使用 `dsh -p`;`apps/cli/tests/built-bin.e2e.ts` 锁定了这一行为:该测试将标准输入输出接入管道,并通过普通 Node 运行构建后的 `lib/bin.js`(e2e Vitest 的 include 包含 `apps/cli/tests`)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。
+
+## 包拓扑
+
+参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。
+
+## 考虑过的替代方案
+
+**保留 `node:util` `parseArgs`,只统一分发。** 已否决:`parseArgs` 没有子命令模型、没有渲染出的帮助、也没有版本标志,因此 `web` 路由和 `--help`/`--version` 仍将保持手写。本仓库其他 CLI 已经选择了 Commander;单独为 `dsh` 引入第二套解析器方式,正是这次变更要消除的碎片化。
+
+**保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。
+
+**使用裸 `dsh ` 位置参数指定替代配置树。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。位置参数会迫使系统把位于首位的 `web` 作为保留 token 分发给另一个解析器,并手工维护一行 `web` 文本,供 `--help` 显示。只有演示和测试调用点需要指定替代配置树,因此 `--config` 标志既能满足这些调用点,又能让默认接口不包含位置参数;这样,`web` 就能在单个程序中成为普通子命令,并由原生 `--help` 展示。
+
+**把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。
+
+**保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。
+
+**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh --config ` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。
+
+## 测试
+
+`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获适配器的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。
+
+## 影响
+
+`dsh` 会渲染 `--help`/`--version`,并以一致方式显式报告解析错误;模式路由不依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 依赖 `commander`,且 Commander 的解析语义(错误字符串和 `exitOverride` 契约)成为 CLI 入口的一部分。`dsh-app-boot` 不提供任何 CLI 解析接口;需要 `--resume` 式解析的消费方通过组合 Commander 来实现。会话恢复通过启动上下文完成,而不使用环境变量;`dsh` 是唯一的终端入口;`dsh-tui-demo` 包是由配置挂载的插件组合包。
diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml
new file mode 100644
index 0000000000..040701d1d6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2
+2026-07-24-project-session-directories.zh.md: 3d8d33fa9fddad010ab319ac4e1f873b69b4e1dd
diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md
new file mode 100644
index 0000000000..0aa3f513d5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md
@@ -0,0 +1,52 @@
+# Agent Note: Project-grouped session directories
+
+Status: implemented
+
+English | [中文](2026-07-24-project-session-directories.zh.md)
+
+## Problem
+
+A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name.
+
+Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state.
+
+## Decision
+
+The JSONL backend stores sessions under a readable project key and gives every session its own directory:
+
+```text
+/
+ ----/
+ /
+ session.jsonl.zstd
+```
+
+Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits.
+
+The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected.
+
+Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores.
+
+The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
+
+The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
+
+Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration.
+
+## Alternatives considered
+
+**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root.
+
+**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts.
+
+**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name.
+
+**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not.
+
+**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic.
+
+## Consequences
+
+Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path.
+
+Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design.
diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md
new file mode 100644
index 0000000000..3d8d33fa9f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md
@@ -0,0 +1,52 @@
+# Agent Note: 按项目分组的会话目录
+
+Status: implemented
+
+[English](2026-07-24-project-session-directories.md) | 中文
+
+## 问题
+
+持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。
+
+每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。
+
+## 决策
+
+JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录:
+
+```text
+/
+ ----/
+ /
+ session.jsonl.zstd
+```
+
+原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。
+
+项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。
+
+在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript(文本记录)时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。
+
+根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
+
+编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
+
+延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。
+
+## 考虑过的替代方案
+
+**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。
+
+**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。
+
+**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。
+
+**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。
+
+**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。
+
+## 后果
+
+共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。
+
+项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。
diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
index 795cb97082..b5945b5342 100644
--- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
@@ -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
-2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764
diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
index 9e93b828d5..377ebd2b3c 100644
--- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
+++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
@@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
-**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
+**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
## Consequences
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
-- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
+- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals.
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
index 996a5705bd..403e95fb30 100644
--- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
@@ -18,14 +18,14 @@ Status: implemented
**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
-**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
+**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。
## 后果
- 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。
-- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
+- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
## Alternatives considered
diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml
index a813a94c95..dbd259b67a 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml
@@ -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
-2026-07-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814
-2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8
+2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be
+2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9
diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md
index 013119508d..be9f821743 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md
@@ -6,13 +6,13 @@ English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md)
## Problem
-On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
+Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
## Decision
-`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL.
+`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode.
-Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary.
+Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist.
## Alternatives considered
@@ -22,6 +22,10 @@ Native Windows coverage protects a target DACL, inspects the written staging fil
**Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one.
+**Assert inherited accounts with `Get-Acl` or `icacls`.** Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts.
+
+**Skip the existing `chmod` calls on Windows.** Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior.
+
## Consequences
-Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged.
+Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md
index 8ae82884c3..fc6ec5232c 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md
@@ -6,13 +6,13 @@ Status: implemented
## 问题
-在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
+原子写入在 POSIX 上以 `0o700` 保护暂存目录、以 `0o600` 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
## 决策
-`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。
+`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。
-Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。
+Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。
## 备选方案
@@ -22,6 +22,10 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成
**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。
+**使用 `Get-Acl` 或 `icacls` 断言继承账户。** 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。
+
+**在 Windows 上跳过现有 `chmod` 调用。** 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。
+
## 影响
-替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。
+替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml
index f907cf276b..7782ea3360 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.i18n.yaml
@@ -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
-2026-07-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683
-2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1
+2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617
+2026-07-20-jsonl-storage-identity.zh.md: d7ba5c646a7adaaa0ebd60fac7b9c2f030361ff9
diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md
index 1ada16791f..1079eb700c 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.md
@@ -6,11 +6,11 @@ English | [中文](2026-07-20-jsonl-storage-identity.zh.md)
## Problem
-JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
+JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
## Decision
-`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets.
+`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every project directory, requires at most one matching encoded session directory with a transcript, parses that file, then validates `header.id === id` and that the selected path either equals `logPath(root, header.cwd, header.id)` or filesystem canonicalization resolves both spellings to the same transcript. `list()` applies the same path validation and rejects duplicate ids across project directories.
The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend` interface therefore needs neither a scope-specific live lookup nor a storage-locator type.
@@ -18,7 +18,7 @@ An existing configured JSONL root must be a readable directory when the plugin l
## Alternatives considered
-**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers.
+**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without making the check depend on a flat global namespace.
**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs.
@@ -26,4 +26,4 @@ An existing configured JSONL root must be a readable directory when the plugin l
## Consequences
-Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation.
+Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. Lookup remains proportional to the number of project directories, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, normalized-project collisions and case aliases, and load-time root validation.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md
index 8027c51dbf..d7ba5c646a 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-20-jsonl-storage-identity.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## 问题
-JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
+JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
## 决策
-`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id` 和 `selectedPath === logPath(root, header.cwd, header.id)`。`list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。
+`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有项目目录,要求名称与该 id 的编码值匹配且其中包含 transcript(文本记录)的会话目录至多有一个,解析其中的 transcript,然后验证 `header.id === id`,并验证选定路径要么等于 `logPath(root, header.cwd, header.id)`,要么经文件系统路径规范化后,两种写法解析为同一份 transcript。`list()` 执行相同的路径验证,并拒绝跨项目目录重复的 id。
协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。
@@ -18,7 +18,7 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
## 考虑过的替代方案
-**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。
+**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需让检查依赖扁平的全局命名空间,也能消除身份缺陷。
**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。
@@ -26,4 +26,4 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
## 后果
-JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。
+JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。查找开销仍与项目目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、项目路径规范化冲突与大小写别名,以及加载时的根目录验证。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml
new file mode 100644
index 0000000000..4267e3b83f
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-empty-model-response-is-retryable.md: f4a6373178efd5ca1ba5882fb2aaf97dffb2526b
+2026-07-24-empty-model-response-is-retryable.zh.md: 4c3afe44140c029d274f34ade97803b958c6d669
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md
new file mode 100644
index 0000000000..f4a6373178
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md
@@ -0,0 +1,36 @@
+# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
+
+Status: implemented
+
+English | [中文](2026-07-24-empty-model-response-is-retryable.zh.md)
+
+## Problem
+
+Providers occasionally return a degenerate completion: a well-formed stream that carries a terminal `stop` finish and zero content blocks — no text, no reasoning, no tool calls. If an adapter maps this shape to a successful `{kind: 'stop'}` finish, the loop logs an empty `assistant/message` and ends the turn as `completed`. Retry never runs, no failure reaches the caller, and a driver such as goal-session consumes a round without progress.
+
+## Decision
+
+An adapter classifies a completed empty response as a provider-boundary failure, and retry policy treats it as transient:
+
+- `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`.
+- `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification).
+- `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful.
+- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`.
+
+Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail.
+
+The classification uses the existing loop machinery — `finishError` → `agent/request-error` → `dsh-llm-retry` — and keeps `agent-loop` provider-neutral. Exhausting the retry budget ends the turn with an explicit `EMPTY_RESPONSE` failure instead of an empty success.
+
+## Alternatives considered
+
+**Detect in the loop or `BlockAssembler`.** One shared implementation, but it moves provider-response judgment into the loop, against "plugins, not loop changes", and the assembler is a pure assembly algorithm. The adapter is where wire facts become harness classification, with the overflow reclassification as exact precedent.
+
+**A stream-transform plugin on the `llm/stream` waterfall.** Provider-neutral and one implementation, but it adds a package plus wiring for what is a boundary fact each adapter can state in a few lines, and default-on behavior would still require touching every bundle.
+
+**Treat whitespace-only or reasoning-only responses as empty too.** Rejected as overreach: those carry model-produced content, and misclassifying a legitimate (if useless) response as a transport-class failure risks retry loops on models that intentionally stop after reasoning. The scope is exactly "zero content blocks".
+
+## Consequences
+
+- A transiently misbehaving provider consumes a bounded retry instead of a turn with no output; a persistently empty model surfaces an actionable `EMPTY_RESPONSE` turn failure.
+- A model that genuinely intends to say nothing (rare, but possible after a tool result) is retried and, if consistently empty, fails the turn. This trade was accepted deliberately: an empty assistant message is indistinguishable from the provider defect and has no value to the user.
+- The `empty-response-retry` ACP snapshot (an authored keyless scenario with a deterministic 1 ms zero-jitter retry overlay, `examples/acp-agent/retry.cordis.yml`) pins the product-visible behavior: a durable `llm/retry` event, no ACP output for the discarded attempt, the recovered reply, and a clean completed turn.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md
new file mode 100644
index 0000000000..4c3afe4414
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
+
+Status: implemented
+
+[English](2026-07-24-empty-model-response-is-retryable.md) | 中文
+
+## Problem
+
+提供方偶尔会返回一种退化的 completion:流本身格式完好,携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning(推理)、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个轮次,却没有取得任何进展。
+
+## Decision
+
+由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题:
+
+- `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。
+- `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。
+- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。
+- `dsh-llm-retry` 把 `EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。
+
+检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。
+
+这套归类使用既有的主循环机制——`finishError` → `agent/request-error` → `dsh-llm-retry`——并让 `agent-loop` 保持提供方无关。重试预算耗尽时,该轮次会以显式的 `EMPTY_RESPONSE` 失败结束,而不是在没有内容的情况下成功结束。
+
+## Alternatives considered
+
+**在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。
+
+**在 `llm/stream` waterfall(瀑布式事件)上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。
+
+**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。
+
+## Consequences
+
+- 一个偶发异常的提供方会消耗一次有界重试,而不是一个没有输出的轮次;一个持续返回空内容的模型则会暴露为用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。
+- 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。
+- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次干净的已完成轮次。
diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md
deleted file mode 100644
index da23bbfa24..0000000000
--- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Agent Note: Agent Client Protocol (ACP) support — drive the coding agent from external editors
-
-Status: implemented
-
-English | [中文](2026-06-14-acp-agent-client-protocol.zh.md)
-
-> Superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). This note records the retired editor-facing bridge design.
-
-## Problem
-
-The harness originally exposed agents only through a readline loop. That surface could carry text, but it gave an editor no structured way to create or resume sessions, correlate prompt completion, stream reasoning and tool activity, render tool-specific UI, ask for permission, or cancel one conversation without disturbing another. ACP defines those interactions as JSON-RPC over stdio, and Zed is the target client used to make concrete compatibility decisions.
-
-The bridge must preserve the harness's existing ownership boundaries. It cannot depend on the concrete agent loop, bypass the tool registry, execute shell commands in the editor, or invent a second source of session truth. stdout is also the protocol transport, so any accidental log output corrupts the connection.
-
-## Decision
-
-`@deepseek-ai/dsh-acp` was a UI/client-driver plugin in the `ui` package group (it now lives in `acp`). It used `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programmed only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It did not change the agent loop and was not a capability-seam implementation.
-
-The bridge implements the following stable session path:
-
-- `initialize` negotiates the protocol version, advertises text plus `resource_link` prompts, and advertises `loadSession`.
-- `session/new` validates an absolute `cwd`, stores it in `SessionHeader`, creates an agent through `ctx.agents`, and returns any composition-backed config options.
-- `session/load` validates the requested cwd against persisted metadata before constructing an agent, reserves the id across the asynchronous resume, replays user/assistant/tool events as ACP updates, and reports the resumed config-option fold.
-- `session/prompt` accepts text and resource links, rejects unsupported or empty content, allows one in-flight prompt per session, and settles against that prompt's owning `turn/end`. An error turn rejects the RPC; other closed turn reasons map through a total ACP stop-reason codec.
-- `session/cancel` calls the queue-aware agent cancel path and settles only the addressed session's prompt.
-
-Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentResult` return the `generic`, `terminal`, or `diff` render-intent variants; the bridge switches on that union and maps it to ACP. Presenter-less tools receive a generic fallback. Bash terminal cards use Zed's capability-gated `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit` convention; the harness still executes the command through `ctx.bash`, preserving sandbox, environment scrub, ownership, and cwd. Clients without that extension receive ordinary text content. Filesystem tools provide diff cards and file locations without hard-coded tool-name branches in the bridge.
-
-Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask.
-
-When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide.
-
-The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved.
-
-Lifecycle ownership is explicit. The bridge holds an `AgentHandle` per live session. Disconnect and Cordis disposal cancel pending prompts, dispose every handle in parallel, await loop quiescence and persistence flush, and then remove the records. Stream notification failures are contained so a vanished client cannot corrupt an agent turn. The ACP app composition loads no stdout logger; a test guards stdout as framed JSON-RPC only.
-
-The current protocol contract lives in the [`dsh-acp` package README](../../../../packages/acp/acp/README.md).
-
-## Alternatives considered
-
-**A prepended `tools/execute` listener that asks on every ACP-owned call** — rejected. It would hard-code permission policy into the UI bridge, ask even when no policy requires it, and could not serve approval requests that arise after execution begins. The shared user-approval seam keeps mechanism, asking policy, and UI answerer separate.
-
-**Inject the concrete `agentLoop`** — rejected. Agent creation, resume, idle observation, and disposal are interface-level ownership operations on `dsh-agent`; a UI plugin does not need a dependency-rule exception.
-
-**Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only.
-
-**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2.
-
-**Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity.
-
-## Consequences
-
-Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple harness sessions over one ACP connection without a loop-specific dependency. The session event log remains the durable source for replay, prompt settlement, cwd, and per-session configuration. Tool presentation and human-answer channels remain extensible plugin contracts instead of ACP-specific behavior.
-
-The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. Runtime model selection was added later through standard session config options by the [LLM catalog and ACP selection Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md).
-
-An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe.
-
-## Verification
-
-The ACP suites cover the in-memory protocol codec, create/load replay, exact prompt settlement, cancellation races, unsupported content, tool presentation, terminal capability fallback, permission outcome mapping, config-option validation and persistence, multi-session isolation, disconnect/disposal quiescence, and HMR cleanup. Snapshot and built-bin tests exercise the app composition, while the real-API e2e self-skips without a key.
diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md
deleted file mode 100644
index ec55922e0a..0000000000
--- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent
-
-Status: implemented
-
-[English](2026-06-14-acp-agent-client-protocol.md) | 中文
-
-> 已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。本 Agent Note 记录已退役的面向编辑器的桥接层设计。
-
-## 问题
-
-harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
-
-桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
-
-## 决策
-
-`@deepseek-ai/dsh-acp` 曾是 `ui` 包组中的 UI/客户端驱动插件(现位于 `acp`)。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。
-
-桥接层实现以下稳定的会话路径:
-
-- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的提示词,并声明 `loadSession` 能力。
-- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。
-- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。
-- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight 提示词,并在该提示词所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。
-- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的提示词。
-
-工具调用的展示仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit`;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。
-
-权限处理是[用户审批 seam](2026-07-06-approval-seam.md)上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 会在故障时保持拒绝。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
-
-当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一条审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级别。
-
-桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。
-
-生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环完全停稳与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
-
-当前的协议契约见 [`dsh-acp` 包 README](../../../../packages/acp/acp/README.md)。
-
-## 曾考虑的替代方案
-
-**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。
-
-**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。
-
-**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。
-
-**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。
-
-**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。
-
-## 后果
-
-编辑器可以通过一条 ACP 连接创建、加载、提交提示词、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、提示词结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。
-
-桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源提示词、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。
-
-空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。
-
-## 验证
-
-ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放后的完全停稳,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。
diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
deleted file mode 100644
index e8426dbf1a..0000000000
--- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Agent Note: Rich ACP bash rendering — the terminal card via the `_meta` convention
-
-Status: implemented
-
-English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md)
-
-> Superseded for ACP by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). Tool render intents remain available to UI transports, but ACP no longer projects them into terminal cards.
-
-## Problem
-
-The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block.
-
-Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card.
-
-## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create`
-
-The ACP spec has a *client-side* terminal sub-protocol — the agent calls the client's `terminal/create` with `{ command, args, cwd, env }` and the **editor** executes the process, then the agent reads `terminal/output` / `wait_for_exit`. That model is wrong for us: our harness executes bash itself through `dsh-bash` (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends.
-
-Studying the two reference agents (2026-06-18) shows neither uses `terminal/create` for their own shell tool — **both keep agent-side execution and emit a `_meta` convention** that Zed special-cases:
-
-- **`claude-agent-acp`** (`tools.ts`, `acp-agent.ts`): gated on `clientCapabilities._meta.terminal_output`. The `tool_call` carries `content: [{ type: 'terminal', terminalId }]` and `_meta.terminal_info.{ terminal_id, cwd }`; output/exit arrive on the `tool_call_update`'s `_meta.terminal_output.{ terminal_id, data }` and `_meta.terminal_exit.{ terminal_id, exit_code, signal }`.
-- **`codex-acp`** (`CodexToolCallMapper.ts`, `TerminalOutputMode.ts`): same `terminal_info` on the call; output via `_meta.terminal_output` (full) or `_meta.terminal_output_delta` (incremental), selected from the same `_meta.terminal_output` capability.
-
-Zed's side (`crates/agent_servers/src/acp.rs`, verified): on a `ToolCall` whose `_meta.terminal_info.terminal_id` is set, it registers a **display-only** terminal (header = `terminal_info.cwd`, label = `tool_call.title`); on a `ToolCallUpdate`, `_meta.terminal_output.data` writes to that terminal and `_meta.terminal_exit.{exit_code,signal}` sets the status. It advertises the capability as `clientCapabilities._meta.terminal_output = true`. `_meta` itself is a spec-blessed ACP extensibility point (typed `{[k]: unknown} | null` on `ToolCall`/`ToolCallUpdate`); the *specific keys* here (`terminal_info`/`terminal_output`/`terminal_exit`) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side.
-
-## Decision
-
-Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` convention, capability-gated, with the ` ```console ` text block as the guaranteed fallback.
-
-1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection.
-2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result).
-3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged.
-4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
-
-## Alternatives considered
-
-- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy.
-- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test.
-
-## Consequences
-
-- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys.
-- **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path.
-- **terminalId collisions.** Deriving it from the per-call `callId` keeps it unique within a session and stable across the call/result pair; never reuse one across calls.
-- **Exit parsed from rendered text.** The exit pill recovers `exit_code`/`signal` by parsing `renderResult`'s status markers rather than threading a structured exit through the event schema (which the pure `presentResult` seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead.
-- **Provider-neutral vocabulary creep.** The terminal presentation widens the `dsh-tools` surface; keep it neutral (no ACP types leak into `dsh-tools`) and only as rich as a second UI consumer would also want.
-
-## Out of scope / non-goals
-
-The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own Agent Note when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes).
diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md
deleted file mode 100644
index ff269e002a..0000000000
--- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
-
-Status: implemented
-
-[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
-
-> 就 ACP 而言已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。工具渲染意图对 UI 传输层仍然可用,但 ACP 不再将其投影为终端卡片。
-
-## 问题
-
-ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
-
-参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
-
-## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create`
-
-ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。
-
-研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理:
-
-- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 与 `_meta.terminal_info.{ terminal_id, cwd }`;输出和退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 与 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
-- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。
-
-Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。
-
-## 决策
-
-保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。
-
-1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。
-2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。
-3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析后的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会替换调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
-4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`)确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。
-
-## 曾考虑的替代方案
-
-- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
-- **通过事件 schema 传递结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。
-
-## 后果
-
-- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。
-- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
-- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。
-- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。
-- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。
-
-## 超出范围 / 非目标
-
-文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。
diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
similarity index 65%
rename from .agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml
rename to .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
index 41265a5b0f..e8e4c59638 100644
--- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
@@ -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
-2026-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c
-2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193
+2026-06-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622
+2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f
diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
index 394c0de708..6acdb6241b 100644
--- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-06-24-workspace-context.zh.md)
+
## Problem
Repository guidance such as `AGENTS.md` belongs in a coding session's effective context so project conventions, build commands, and review rules arrive without repeated user pasting. The stdio and ACP products need the same behavior, isolated by session cwd: a global system-prompt section leaks one workspace's files into another live ACP session.
diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md
new file mode 100644
index 0000000000..f165d08108
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md
@@ -0,0 +1,89 @@
+# Agent Note: 工作区上下文指令文件
+
+Status: implemented
+
+[English](2026-06-24-workspace-context.md) | 中文
+
+## 问题
+
+`AGENTS.md` 等仓库指引应当进入编码会话的有效上下文,使项目约定、构建命令和评审规则无需由用户反复粘贴即可生效。stdio 与 ACP(Agent Client Protocol)产品需要具备相同行为,并按会话 cwd 隔离:全局系统提示词章节会把一个工作区的文件泄漏到另一个仍在运行的 ACP 会话中。
+
+相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。
+
+生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。
+
+## 决策
+
+该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。
+
+插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。
+
+### 文件名与优先级
+
+默认的逐目录候选列表是 `['AGENTS.md', 'CLAUDE.md']`。该列表可通过 `instructionFileCandidates` 配置;`AGENTS.md` 是普通的第一候选项,而不是隐藏优先级。一个目录中只加载第一个存在的普通文件候选项。使用默认值时,`AGENTS.md` 是原生文件,`CLAUDE.md` 是兼容性回退。第二个列表 `localInstructionFileCandidates`(默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`)会在同一目录的基础文件后加载叠加式本地覆盖层;[默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策。
+
+候选条目必须是同一目录中的文件名。空条目、`.`/`..`,以及包含 `/` 或 `\` 的条目会被忽略。其他同目录名称可以显式选择加入;规则目录和导入语义不属于本契约。
+
+用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。
+
+### 基线前缀
+
+agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。
+
+插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。
+
+恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。
+
+基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。
+
+### 动态发现与刷新
+
+第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: ` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。
+
+内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。
+
+动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。
+
+shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。
+
+### 重复抑制与变更检测
+
+每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
+
+协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。
+
+路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
+
+被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。
+
+系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。
+
+### 字节预算与有界读取
+
+`maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。
+
+`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。
+
+## 考虑过的替代方案
+
+**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。
+
+**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。
+
+**在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。
+
+**解析渲染后的标题或隐藏注释以恢复已加载状态。** 不予采纳,因为指令正文可能包含相同文本,导致无提示的误报。持久化 JSON 元数据提供明确且对模型不可见的状态通道。
+
+**使用模型总结文件。** 不予采纳,因为指令文件本身已经是经过整理的摘要;再执行一次模型调用既不确定,也可能抹掉边界情况要求。使用带字节预算的确定性全文更简单。
+
+## 影响
+
+工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。
+
+仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。
+
+系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。
+
+## 延后事项
+
+从 bash 派生路径报告、递归启动扫描、文件监视器、小写默认名称、`.claude/CLAUDE.md`、`.claude/rules/*.md`、导入指令、ACP `additionalDirectories`、信任确认和模型生成摘要均延后处理。项目目录中的 `.local.` 覆盖层现已默认加载([默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策);用户全局覆盖层、目录规则系统和导入仍需要各自的优先级与信任设计。
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
index 84534e7d39..ab3de720a7 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
@@ -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
-2026-07-06-approval-seam.md: cc37912144a99623a0cc699e2981c979278f42db
-2026-07-06-approval-seam.zh.md: a97e2e5306d5661aa32f433192267a363237dbb0
+2026-07-06-approval-seam.md: 729a9cdc5b723c5ddcb13ef6452d7421823fb19f
+2026-07-06-approval-seam.zh.md: c51739b96f3e2d5091767bdc07795866df449fa1
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
index cc37912144..729a9cdc5b 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
@@ -69,7 +69,7 @@ The seam also owns the session-scoped `'ask' | 'never'` policy described by [the
The ACP bridge answers only for an exact agent object owned by its session map. It sends `session/request_permission` with the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. This channel is machine policy between an automated client and its agent, not ACP presentation.
-The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
+The answerer routes through the bridge's exact-agent ownership check described by [the automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
#### Audit, and what the model sees
@@ -135,5 +135,5 @@ In-repo precedents this design copies or contrasts with:
- The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses.
- `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges Agent Note](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer.
- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services.
-- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
+- [The automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
- The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it.
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
index a97e2e5306..c51739b96f 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
@@ -69,7 +69,7 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `
ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。
-应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
+应答者通过[仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
#### 审计,以及模型看到什么
@@ -135,5 +135,5 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。
- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 Agent Note](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。
- [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。
-- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
+- [仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。
diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md
deleted file mode 100644
index f5b76fae5b..0000000000
--- a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md
+++ /dev/null
@@ -1,194 +0,0 @@
-# Agent Note: Plan mode — a logged per-agent session mode
-
-Status: implemented
-
-> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed.
-
-> **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces.
-
-## Problem
-
-Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log.
-
-The extension seams already supplied the surrounding pieces: [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) shapes guidance per step and the shipped request is logged in `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); [`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) carries the approval question and corrective feedback ([ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); `SessionEventMap` carries durable per-agent facts ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes.
-
-## Decision
-
-The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/`, owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only required definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now.
-
-The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of mode guidance — no section, filtering, or gate. Loading `dsh-mode` still contributes one stable `exit_plan_mode` schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries.
-
-A mode's whole surface is soft: a `mode:policy` prompt section renders the active definition's guidance, while `exit_plan_mode` remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is `plan`. A transition therefore changes only the system-prompt portion of the attributable `request/header` on the next step, keeping [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review.
-
-The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed.
-
-## High-level API
-
-### A plan-mode session end to end
-
-The user switches the session to plan mode through the ACP mode picker or `/plan [message]` in a terminal front door, and from the next step every request ships the configured plan guidance section. When the optional message is present, that same command submits it into the affected step. The `exit_plan_mode` schema was already present in default and remains byte-identical.
-
-The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option.
-
-When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface.
-
-On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents.
-
-### Deployment configuration
-
-Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The deployment must provide the complete `plan` section; the package embeds no model instructions. Additional modes use the same config map:
-
-```yaml
-- id: mode
- name: '@deepseek-ai/dsh-mode'
- config:
- modes:
- plan:
- section: |
- You are in plan mode: explore and design, then present the
- plan for approval through exit_plan_mode.
-```
-
-A definition is exactly `{ section }` — there is deliberately no per-mode tool list and no enforcement field ([FAQ](#faq)). Definition names use the lowercase slash-command subset `/^[a-z][a-z0-9_-]*$/u`; `default` is reserved (the absence of policy) and rejected as a key. An invalid name or unknown definition key — a `tools` list or an `access` cap included — fails validation at load; an unknown mode name fails loudly at `set()` time.
-
-### In the terminal
-
-Terminal front doors get one entry command per configured definition through the plugin-owned command registry (`@deepseek-ai/dsh-commands`): `dsh-mode` registers `/plan [message]` for the required definition and, for example, `/review [message]` when `review` is configured. Each command records its named switch; a non-empty optional message is trimmed and passed to `agent.steer()`, which places it in a running agent's next step or delegates to `send()` for a new idle turn. The command name and result stay out of model history, while that explicit message is logged as an ordinary user message under the selected mode. The synthetic `default` entry contributes no command. The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the composed user-interaction provider's prompt queue that `ask_user_question` already uses.
-
-### Over ACP
-
-The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review `detail` because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)).
-
-### For agent creators
-
-`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. There is no creation-time mode option — a caller selects through `set()` before the first turn, which flushes identically. There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md).
-
-## Detailed design
-
-### Vocabulary
-
-```text
-'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface,
- // whole-value replace — the last one in the log wins
-DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable
-```
-
-The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for request-header facts (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded`).
-
-### Config and the resolve step
-
-```text
-interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary
-interface ModeConfig { modes: Record } // plan is required and owns its complete prompt
-resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud:
- // missing plan, 'default', blank sections, and unknown keys rejected
-```
-
-The one-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives.
-
-### The fold, the service, and the flush
-
-`foldMode(events)` is pure (exported for reconstructors and tests) and folds the append-only session log directly; `mode/set` is not a surface node, so compaction cannot shadow it. `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET — drops a no-op (target equals pending, else current), and otherwise records `{ mode, narrate }` in a `WeakMap` pending-intent slot. It cannot append immediately because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn.
-
-Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, and `agent/turn-continuation` fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended `agent/request-error` wrapper delegates through the composed policy and asynchronous backoff, then flushes only a `retry` decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit `session/event` observers are observe-only), so every step runs under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq).
-
-### The soft layer: a computed section and a stable exit schema
-
-The registered prompt section reads the calling agent's mode from `AssembleContext.agent` and resolves to the active definition's guidance or `''`. The loop renders per step and logs a complete `request/header` whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary prompt churn.
-
-The guidance contribution is `{ name: 'mode:policy', order: 50, text: context => … }`: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. `exit_plan_mode` is registered once through `ctx.tools` and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without `dsh-mode` lacks that one binding. There is NO `tools/pre-execute` listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam.
-
-### `exit_plan_mode`
-
-`defineTool` has one required `plan: string` argument. Native execution records it in the ordinary `tool/call`; Code Mode records the outer `run_code` source before execution and appends the normalized nested arguments in `tool/code-dispatch` after the dispatch settles. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), rejects any folded mode other than `plan`, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select `ctx.userInteraction.ask()` review whose `detail` is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one `Approve` selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to `default` and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective `isError` and leaves the mode in `plan`.
-
-Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction `detail` independently carries the same plan on every provider. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq).
-
-### Dependencies and surfaces
-
-`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, and reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`); its only UI-facing edges are optional type-only peers (`dsh-commands` for the per-definition entry commands). Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. Terminal front doors need no mode-specific code: `dsh-mode` itself registers each definition's command on the command registry when one is composed (an optional type-only peer edge on `dsh-commands`), and the exit review rides the composed user-interaction provider's prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today.
-
-### The recorded scenario and the harness op
-
-`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue. The `plan-mode` scenario enters plan before turn 1, runs a real `cat` under the independently configured sandbox, presents a plan through `exit_plan_mode`, receives scripted approval, then edits on the next step. The first `request/header` contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. `plan-mode-reject` pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier.
-
-### The mechanical tail
-
-No new cordis event is declared (`mode/set` rides `session/event`; the listeners attach to existing waterfalls), so the events catalog is untouched. Regenerated in the same change: the persistence log catalog (`mode/set`), the services catalog (`ctx.modes`, JSDoc-complete), the config catalog (`ModeConfig`), the tool catalog (`exit_plan_mode`), the producer/consumer map and doc graphs, and the module graph. Repo plumbing: a root tsconfig `paths` entry, the new group's README plus a [packages map](../../../../packages/README.md) row (a new top-level group is the deliberate act that table names), an `architecture.md` capability-services row for `ctx.modes` (budget-checked), and the cookbook row upgrade.
-
-## Deferred
-
-Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
-
-The ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas.
-
-## FAQ
-
-Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Consequences](#consequences).
-
-**When does a user's mode flip take effect?** At the next pre-assembly boundary: `agent/prompt-submit` covers the first step, `agent/turn-continuation` covers a normal successor, and the post-composed `agent/request-error` retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships.
-
-**When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has.
-
-**What happens on resume when the config no longer defines the folded mode?** A folded mode name the current config no longer defines behaves as the default mode without a notice, so the session neither gains a substitute restriction nor becomes unusable. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds.
-
-**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling.
-
-**Why is there no per-mode tool allowlist?** Because "which tools are safe in a planning mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger), a mode restrains by its section and the exit review; the exposure is an accepted cost ([Consequences](#consequences)).
-
-**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode; a creation-time mode option and automatic forwarding by subagent providers are deferred together ([Deferred](#deferred)).
-
-**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`.
-
-**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the [automation-only protocol decision](../simplification/2026-07-23-acp-automation-only-protocol.md). A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
-
-## Prior art
-
-A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on.
-
-The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control.
-
-The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract.
-
-The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement ([FAQ](#faq)).
-
-## Alternatives considered
-
-**Permission modes as the concept (the Claude Code shape).** One `permissionMode` fusing approval policy and tool policy. Here those are two axes with two owners: the approval seam owns "who answers this question", modes own "what surface does the model get". ACP models them as related but distinct (a mode may select an approval policy later — a mode definition gains a field, not a merger).
-
-**A capability-seam trio.** Interface/implementation/consumer fits a swappable backend; a mode's variable parts are config values, not implementations. Splitting would manufacture an empty implementation package — the same "don't split preemptively" call the approval seam and [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) made.
-
-**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling.
-
-**A per-mode tool allowlist with a deny-by-default gate (the first shipped shape).** Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist. The general dimension is parked on effects self-declaration ([Deferred](#deferred)); the consequence — plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately, priced in [Consequences](#consequences).
-
-**An `access` sandbox cap on the mode (the second shipped shape).** Also removed before release. `ModeDefinition.access` clamped the bash seam's per-call sandbox resolution to a mode-declared ceiling (a `bash/resolve-mode` waterfall + ladder-min listener, with guards withholding bash under an unconfinable executor and denying escalation mid-mode). The state stayed orthogonal — the clamp never wrote the sandbox knob — but the AXES did not: entering plan changed what the sandbox enforced, fusing the collaboration stance with an enforcement level and contradicting the Codex-shaped separation the review converged on (Plan/Default presets never touch sandbox or approval settings). One user-visible symptom of the fusion: flipping the sandbox option to `workspace-write` while planning silently did nothing. The cap, the waterfall, and the mode→bash dependency edge were removed together; a deployment gets kernel-enforced read-only planning by pairing the mode with the independent sandbox-mode option, and a mode-triggered PRESET (a mode definition bundling suggested knob values, applied as ordinary knob switches) can return later without re-fusing the axes.
-
-**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free.
-
-**Mode flips as `context/message` via `agent.inject()`.** Reuses an existing turn-enclosure path, but puts policy state into the model transcript — the model does not need to be told twice (the section already tells it), and a log-only fact should not occupy surface.
-
-**A plan-file store (`.plans/` directory).** A second durable home for what the log already carries replayably; a deployment wanting files can add a tool that writes them. One home per fact.
-
-**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition.
-
-**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement.
-
-**Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there.
-
-**Exit by prose or steering instead of a tool.** No artifact and no approval moment — the tool's argument IS the reviewable plan, and its review question is what gives the human a structured yes/no attached to the exact transition.
-
-## Consequences
-
-What holds now, pinned by the unit, protocol, snapshot, and real-API tiers:
-
-- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by a matching complete `request/header` on the next changed step.
-- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result.
-- In default mode the plugin contributes no mode section but does contribute the stable `exit_plan_mode` schema; a deployment without `dsh-mode` lacks that binding.
-- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes.
-- Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning.
-- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`.
-- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; each human-facing surface's user-interaction provider carries the review.
-- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row.
-
-The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither.
diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml
similarity index 63%
rename from .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml
rename to .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml
index 26ef840cd0..fb39bd26db 100644
--- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml
@@ -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
-2026-06-14-acp-agent-client-protocol.md: da23bbfa247bc2423072477cc4b6277485df1c9c
-2026-06-14-acp-agent-client-protocol.zh.md: ec55922e0aee57169d8bbf44c3d91b18ea83041e
+2026-07-08-background-subagent-tasks.md: 12b34f2a28cfa311a48904cd5396ec16d9123641
+2026-07-08-background-subagent-tasks.zh.md: 58f035a14d55bc0b4e7f670111909074c3f53b2b
diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md
index 83ea99dc75..12b34f2a28 100644
--- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md
+++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-08-background-subagent-tasks.zh.md)
+
## Problem
The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially.
diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md
new file mode 100644
index 0000000000..58f035a14d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md
@@ -0,0 +1,64 @@
+# Agent Note: 后台 subagent 任务
+
+Status: implemented
+
+[English](2026-07-08-background-subagent-tasks.md) | 中文
+
+## 问题
+
+[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。
+
+subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放契约。
+
+## 决策
+
+每个 `dsh-tool-subagent` 实例都可以公开 `run_in_background`,由 `enableRunInBackground` 控制,且默认启用。禁用该功能的实例不包含此参数,并会在执行时拒绝强制传入的后台参数。提供方选择仍属于部署配置,因此一个实例仍然只为一个提供方注册一个名称可区分的工具。
+
+后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`task_output`、`task_list` 和 `task_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。
+
+前台调用保留其同步契约:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。
+
+对于后台调用,工具会验证父级,并在调用 `ctx.tasks.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制接口和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。
+
+任务注册按以下方式映射 subagent seam:
+
+- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。
+- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已就绪的子级。
+- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。
+- `readOutput` 不存在。任务存活期间,`task_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。
+
+## 生命周期
+
+后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent 资源释放会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级资源释放,避免泄漏子 agent 和会话。
+
+完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经释放了注入目标,该通知将被丢弃;生命周期保证是清理,而不是通知。
+
+## 模型引导
+
+通用任务提示词教会模型一套共享的做法:保留 id;继续独立工作,而不是忙等轮询;在回答前收集相关任务;终止无关工作。subagent schema 只补充说明:后台模式返回 task id,且 `task_output` 用于收集结果。无论模型是否遵循提示词,授权和拥有者清理都会强制执行运行时边界。
+
+## 备选方案
+
+### subagent 专用的等待、输出和停止工具
+
+能力专用工具会重复任务协议,再教一套收集与停止习惯,并增加多个提供方实例的复杂度。通用运行时在不改变工具「每个实例对应一个提供方」形态的前提下,提供了所需行为。
+
+### 在拥有者关闭后存续
+
+该方案需要持久化的任务状态、子会话恢复、延迟结果交付通道,以及对被遗弃拥有者的处理策略。以拥有者为作用域的清理为进程内工作界定了明确生命周期。持久作业需要单独设计。
+
+### 隔离客户端不做拥有者检查
+
+agent 和日志可能以会话为作用域,但任务注册表和可预测 id 属于运行时全局范围。因此,通用拥有者防线同样适用于 subagent 和所有其他生产者。
+
+### 增量子 transcript 输出
+
+将子级历史以流式方式写入父级,会模糊日志边界,并使提供方行为分化。此接口只公开最终输出;更丰富的观察应由会话或 UI 工具承担。
+
+## 测试
+
+单元测试覆盖固定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方就绪前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照覆盖固定了面向模型的 schema。
+
+## 影响
+
+父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者释放之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。
diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml
similarity index 61%
rename from .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml
rename to .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml
index f9049645c3..db23b997c0 100644
--- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml
@@ -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
-2026-06-18-acp-terminal-and-tool-rendering.md: e8426dbf1a0e3e4f9d9857baa19945cee6eee4b3
-2026-06-18-acp-terminal-and-tool-rendering.zh.md: ff269e002a0ecea8c0bacf53fe85e553a6b9b9d5
+2026-07-09-bash-backed-grep-glob-discovery.md: 9c25afb44885ca2519c5d74a3d721b34fe3561de
+2026-07-09-bash-backed-grep-glob-discovery.zh.md: d0dee8e49e0a500c87afbd59423fb65416c1dcc8
diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
index 64fa232831..9c25afb448 100644
--- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
+++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-09-bash-backed-grep-glob-discovery.zh.md)
+
## Problem
The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need.
diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md
new file mode 100644
index 0000000000..d0dee8e49e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md
@@ -0,0 +1,170 @@
+# Agent Note: 由 Bash 支持的 grep 与 glob 发现工具
+
+Status: implemented
+
+[English](2026-07-09-bash-backed-grep-glob-discovery.md) | 中文
+
+## 问题
+
+harness 需要面向模型的 `glob` 和 `grep` 工具,但如果将它们实现为 `ctx.fs` 提供方的方法,就会把本地产品便利功能变成所有文件系统后端都必须实现的契约。本地工作区发现天然适合由进程支持的 `rg` 工作流;远程或虚拟文件系统后端可能公开自己的搜索 API,可能无法共享本地 `ripgrep` 视图,也可能完全不支持发现。文件读取/写入/编辑 seam 尚未证明此需求前,v1 不应要求每个文件系统后端都实现搜索。
+
+搜索输出还受到两层不同的预算约束。工具需要足够多的原始 `rg` 输出,才能计算稳定的逻辑结果;模型则只能收到有界预览,并在格式化结果超出内联预算时获得恢复路径。通用落盘策略只能看到最终工具结果,因此无法恢复搜索工具已经省略的匹配项。搜索工具必须自行负责保留,并尽力落盘格式化结果。
+
+## 决策
+
+`glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,由 bash seam 支持,不会成为新的 `ctx.fs` 提供方方法。加载插件时,该包(package)执行 `command -v rg >/dev/null 2>&1`:先通过 `ctx.bash.resolve(request)` 解析请求,再通过 `ctx.bash.run(spec)` 运行;如果命令以非零状态退出,该包会记录警告,并且既不注册工具,也不注册提示词章节。如果探针无法启动、超时、中止、被终止,或没有产生退出码,插件加载会明确失败,因为这意味着 bash 执行器损坏,而不是可选二进制文件缺失。注册后,执行流程同样依次调用 `ctx.bash.resolve(request)` 与 `ctx.bash.run(spec)`,并使用工具组装的固定 `rg` 命令模板。工具层负责 schema、参数验证、shell 引用、结果解析、结果格式化、保留、格式化结果落盘交接,以及超时声明。bash 执行器负责请求默认值解析与上限控制、子进程执行、进程组终止、环境清理、原始输出捕获,以及在本地、沙箱或远程 bash 实现之间替换后端。
+
+这些工具不使用 `ctx.bash.start()`,也不创建模型可见的后台任务。从 agent loop(智能体循环)的视角看,它们是普通前台工具:只有当 `rg` 命令退出、超时、中止或失败后,工具调用才返回。`defineTool({ timeoutMs })` 声明协作式工具调用预算,`@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;工具会在 `resolve()`/`run()` 前将该信号转发给 bash 请求。bash 后端自身的超时仍作为第二层安全上限;先触发的中止生效。
+
+这些工具使 `path` 与 Claude Code 的搜索工具保持一致,但将解析绑定到 bash workdir,而不是 `ctx.fs`。工具从 `exec.agent?.session.header.cwd` 派生 bash 请求 workdir,与 `dsh-tool-bash` 和 `dsh-tool-fs` 一致;如果会话没有 cwd,它会省略 `request.workdir`,由 bash 实现通过 `resolve()` 应用其配置的 cwd 或进程 cwd。对于 `grep`,`path` 是可选的 ripgrep 目标,可以是文件或目录;省略时使用已解析的 bash workdir。对于 `glob`,`path` 是可选的目录搜索根;省略时同样使用已解析的 bash workdir。相对 `path` 值基于该 workdir 解析。只要可行,返回路径就会显示为相对于已解析 bash workdir 的形式;只有在共置部署中,bash workdir 与文件系统 `read` 根指向同一个工作区时,这些路径才保证可以继续读取。v1 会记录这项部署要求,但不执行跨服务运行时验证。在形成共享工作区/根契约或提供方专用搜索后端之前,远程或虚拟文件系统搜索保持暂缓。
+
+该包不注入 `fs`,而是注入 `tools`、`systemPrompt` 和 `bash`;它有意读取 `spillStore` 时使用 `ctx.get('spillStore')`,而不使用静态注入,因为格式化结果落盘是可选功能。现有 `@deepseek-ai/dsh-tool-fs` 部署若只需要 `read`/`write`/`edit`,则无需加载 bash。加载搜索功能的部署则必须让 bash 执行器环境可以使用 `rg`,这些工具才会进入模型可见 schema。
+
+### 包结构
+
+v1 包保持精简。`@deepseek-ai/dsh-tool-fs-search` 内部的源代码布局如下:
+
+```text
+src/index.ts
+src/glob.ts
+src/grep.ts
+src/search-core.ts
+src/shell-quote.ts
+```
+
+`glob.ts` 和 `grep.ts` 各自负责参数验证、命令构造、结果解析、格式化和注册。`shell-quote.ts` 是一个共享辅助模块,因为 shell 引用是两个工具都必须经过的安全边界;`search-core.ts` 是另一个共享模块(实现时对原四文件方案所作的修订):`SEARCH_*` 错误词汇、bash 运行与原始输出获取、格式化结果落盘交接,以及 workdir 相对显示,在两个工具中完全相同。若在每个工具中重复这套精细管道,正是对称性约定所指出的漏提取问题。命令构造器禁止自行拼凑引用,也不能把未经引用、由模型控制的值直接连接到 shell 命令中。
+
+### Schema 与配置
+
+`glob` 公开精简的发现形状:
+
+```ts
+interface GlobArgs {
+ pattern: string
+ path?: string
+}
+```
+
+`grep` 公开 OpenCode 风格的最小形状:
+
+```ts
+interface GrepArgs {
+ pattern: string
+ path?: string
+ include?: string
+}
+```
+
+常规预算不会进入面向模型的 schema。`@deepseek-ai/dsh-tool-fs-search` 拥有以下带默认值并经过验证的配置字段:
+
+| 字段 | 默认值 | 作用 |
+|---|---:|---|
+| `globMaxResults` | `100` | 内联保留的最大路径数;与 Claude Code 的默认 `GlobTool` 结果上限一致。 |
+| `grepMaxMatches` | `250` | 内联保留的最大扁平匹配数;与 Claude Code 的默认 `GrepTool` `head_limit` 一致。 |
+| `grepMaxLineBytes` | `2000` | 每条匹配行预览保留的最大字节数,通过 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` 应用。 |
+| `rawOutputMaxBytes` | `20000000` | 工具会解析的完整原始 `rg` stdout 最大字节数;与 Claude Code 的 ripgrep 原始缓冲区一致。 |
+| `timeoutMs` | `30000` | 附加到两个工具定义并由 `@deepseek-ai/dsh-timeout-policy` 强制执行的工具调用超时。 |
+
+`globMaxResults` 和 `grepMaxMatches` 使用 `ItemRetainer({ kind: 'head' })`。`grepMaxLineBytes` 针对每条匹配行使用 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`,使预览截断保留 UTF-8 边界。这遵循[工具结果保留库](../architecture/2026-07-06-tool-result-retention-library.md)对发现条目的映射:收集完整结果,在内联结果中保留头部条目,并将路径映射、分组和逐行预览放在保留器外部。v1 的 `grep` 不公开 `case_insensitive`、`head_limit`、`offset`、`count`、多行、上下文行、输出模式或文件类型过滤器。模型如需周边上下文,可使用 `read` 读取匹配文件;如需后续结果,则遵循返回的落盘定位符所给出的检索提示。
+
+Claude Code 的数值只是两层预算的参考点,并非面向模型 schema 的先例。其专用搜索工具会缓冲最多 20 MB 的原始 ripgrep 输出用于内部处理;在非 WSL 平台上使用 20 秒 ripgrep 超时,在 WSL 上使用 60 秒,之后才在模型看到结果前应用搜索专用上限:`GrepTool` 默认 `head_limit = 250`,并持久化超过 20,000 个字符的格式化结果;`GlobTool` 默认最多 100 条路径,并持久化超过 100,000 个字符的格式化结果。此 Agent Note 采用同样的原始缓冲区和内联数量默认值,将默认搜索超时设为 30 秒,并通过本 harness 的 `ctx.spillStore.saveText()` 路径恢复格式化结果。
+
+`path` 字段沿用与 Claude Code 相同的区分方式:`grep.path` 是文件或目录形式的 ripgrep 目标,`glob.path` 则是目录搜索根。v1 不为这些工具公开单独的 cwd/workdir 参数。
+
+`include` 是一个正向 glob 过滤器,不是列表,也不是排除语法。系统会预先拒绝逗号分隔或取反的 include 模式,并返回结构化参数错误。shell 命令中使用的每个模型控制值,包括 `pattern`、`path` 和 `include`,都必须经过包私有的 shell 引用辅助模块。
+
+### 执行
+
+`glob` 构建固定的 `rg --files` 命令,并以解析后的目录搜索根为根(提供 `path` 时使用该值,否则使用 bash workdir):`rg --files --glob --sort=modified --no-ignore --hidden`,另加针对 `.git`、`.svn`、`.hg`、`.bzr`、`.jj` 和 `.sl` 的 VCS 元数据排除项。这样既与 Claude Code 的隐藏/忽略文件发现和修改时间排序保持一致,也避免宽泛搜索包含 VCS 内部文件。工具逐行解析路径,只要可行就将结果映射为相对于 bash workdir 的路径,将每条路径推入 `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`;当保留结果达到上限时,它会格式化完整的已排序路径列表,作为落盘产物。
+
+`grep` 构建固定的逐行 `rg --json` 命令,作用于所提供的文件/目录目标(提供 `path` 时使用该值,否则使用 bash workdir),从而无需按冒号拆分,就能解析文件路径、行号和行文本。它消费 `match` 记录,将格式错误的 JSON 或匹配记录视为 `SEARCH_FAILED`;只要可行,就将结果路径映射为相对于 bash workdir 的路径;通过 `grepMaxLineBytes` 应用逐行预览保留,将每个匹配推入 `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`,然后只按文件分组内联输出中保留的预览匹配。落盘产物保存完整的格式化匹配列表,而不是只保存省略的尾部,因此检索提示指向模型已经看到的同一逻辑结果。
+
+原始 `rg` stdout 是内部传输细节。工具请求 `stdoutMaxBytes: rawOutputMaxBytes`,并通过 `ctx.bash.resolve()` 解析;只有当执行器在该上限内返回未截断的 stdout 时,工具才解析 `stdout.text`。如果 stdout 超过 `rawOutputMaxBytes`,或者执行器仍返回 `stdout.truncated`,工具会以明确的搜索错误失败,要求模型缩小 `pattern`、`path` 或 `include`。工具绝不向模型公开原始 `rg` 输出或 bash 原始落盘路径。
+
+只有 stdout 是解析源。对于无效模式、注册后运行时 `rg` 消失,以及搜索失败,stderr 作为诊断文本;如果 bash 截断 stderr,工具会使用保留的 stderr 尾部并附加截断说明,不会读取 `stderr.spillPath`。
+
+如果 `ctx.bash.run()` 因工具超时或调用方取消触发而报告 `aborted`,工具会返回结构化失败,而不是假装没有匹配项。如果 bash 自身的超时先触发,工具同样会以明确的超时消息失败。非零 ripgrep 退出语义由工具负责:退出码 0 表示存在匹配并成功;退出码 1 表示没有匹配但成功;无效模式、运行时 `rg` 消失或无法访问搜索 workdir 则表示失败。
+
+搜索失败使用包自有的 `HarnessError` 子类与 `SEARCH_*` 代码,而不使用 `FsErrorCode`,因为这些工具不是 `ctx.fs` 提供方操作。v1 的词汇包括 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 和 `SEARCH_ABORTED`。缺少必填字段、空字符串或不支持的取反/列表式 `include` 值等模型参数验证失败,仍作为普通工具参数错误处理。
+
+### 格式化结果落盘
+
+`ctx.spillStore` 是可选服务,仅用于面向模型的格式化结果。这是代码库中首个工具自有落盘调用模式;此设计有意为之,因为搜索保留属于条目级策略:`globMaxResults` 限制路径数,`grepMaxMatches` 限制匹配数,而工具此时仍持有完整逻辑结果。通用 `dsh-spill-policy` 会在 `tools/post-execute` 阶段限制最终文本字节数;到那时搜索工具已经省略后续路径或匹配,策略无法恢复它们。
+
+当搜索产生的逻辑结果数超过内联上限,且 `ctx.spillStore` 存在时,工具会通过 `saveText()` 保存完整的格式化结果。落盘所有者是调用 agent 的会话头 id(`exec.agent?.session.header.id`);缺少该所有者时,搜索会保留内联结果,并报告完整结果无法保存。落盘来源是工具执行身份:`{ toolName: exec.name, callId: exec.callId, label: 'result' }`。建议文件名为 `grep-results.txt` 和 `glob-results.txt`;落盘后端仍将它们视为提示,而不是路径。
+
+如果落盘存储不存在、调用没有会话所有者,或保存失败,工具仍返回内联页和页脚,说明完整结果无法保存。格式化结果落盘存储不可用本身绝不能把搜索成功变为 `isError` 结果。
+
+bash 原始输出流与格式化搜索落盘产物是两个不同的产物。原始 `rg` stdout 只会在所请求的 bash stdout 上限内于内存中解析;格式化落盘产物则是 `ctx.spillStore.saveText()` 生成的稳定、面向模型的恢复定位符。
+
+### 结果形状
+
+带有成功格式化落盘的受限 `glob` 结果会返回内联页与落盘通知:
+
+```text
+
+
+(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.)
+```
+
+带有成功格式化落盘的受限 `grep` 结果会返回分组后的预览匹配与落盘通知:
+
+```text
+Found N of M matches
+
+
+Line 12: ...
+
+(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.)
+```
+
+如果完整逻辑结果未超过内联上限,系统不会创建格式化落盘产物。如果完整逻辑结果过大但无法格式化落盘,页脚会说明结果已受限,完整结果无法保存。`truncated`/省略计数是预算事实,并不表示搜索不完整;超时、无效正则表达式、运行时 `rg` 消失、无法访问 workdir、原始输出溢出、跳过二进制文件和解析失败,仍属于工具领域的错误或不完整字段。
+
+## 考虑过的替代方案
+
+**将 `glob`/`grep` 放在 `ctx.fs` 上。** v1 不采用:这会迫使每个文件系统后端增加搜索 API,并使本地 ripgrep 行为成为提供方 seam 的一部分。搜索是有用的产品行为,但不像 `readText` 或 `writeText` 那样属于通用文本存储原语。
+
+**直接从 `dsh-fs-local` spawn ripgrep。** 此 Agent Note 的 v1 不采用:直接 spawn 提供最简洁的 argv 边界、stdout/stderr 控制与提前停止控制,但会重复 bash seam 已负责的进程执行事项,包括环境清理、进程组终止、超时传播、沙箱/远程执行器替换,以及有界输出捕获。如果 bash 支持的搜索被证明过于依赖 shell 字符串,或必须支持前台流式输出,该方案仍是合理优化。
+
+**通过 `ctx.bash.start()` 实现流式提前停止。** 不采用:`start()` 会创建模型可见的后台任务语义,包括 task id、所有者 token、`bash_output`、`bash_kill`、完成通知,并且没有内置超时。`grep` 需要前台工具结果,而不是后台 bash 工作流。如果将来必须流式搜索,正确的抽象是在 bash/进程 seam 上增加前台流式进程句柄,而不是借用公开后台任务 API。
+
+**向模型公开 bash 原始落盘路径。** 不采用:bash 原始落盘路径包含原始 `rg` stdout(对于 grep 即 `rg --json` 记录),并非稳定的格式化搜索结果。搜索只把原始 stdout 当作内部传输;模型恢复使用通过 `ctx.spillStore.saveText()` 保存的格式化结果。
+
+**先为 bash 输出规范化增加 `spillStore.saveFile()`。** 此 Agent Note 的 v1 不采用:未来规范化 bash 时,`saveFile()` 可以帮助将现有执行器落盘文件移动到会话范围的落盘存储,但搜索只需在生成面向模型的产物前,在内存中获取有界的原始 `rg` stdout。`saveText()` 足以保存格式化搜索结果。
+
+**依赖通用 `dsh-spill-policy`。** 不采用:通用 post-execute 落盘只能看到最终工具结果。如果 `grep`/`glob` 内联返回第一页,通用策略无法恢复省略的结果。搜索工具必须在返回有界的面向模型文本前,自行保存完整的格式化结果。
+
+**公开 Claude Code 的完整 `GrepTool` schema。** v1 不采用:`output_mode`、上下文标志、多行、`head_limit`、`offset`、`case_insensitive` 和类型过滤器会使面向模型的接口变成 ripgrep 包装层。本 harness 将常规预算与续传机制保留在部署策略和落盘产物中。
+
+**保留提前停止搜索,并省略格式化落盘产物。** 此提案不采用:提前停止效率更高,却不给模型检查后续结果的路径。所选 v1 优先保证结果可恢复性与实现简洁性,并以 `timeoutMs`、`rawOutputMaxBytes`、bash 后端上限和格式化落盘产物作为安全后备。
+
+**先扩展 bash seam,增加原始输出读取器。** 不采用:可移植的 `readRawOutput(ref, maxBytes)` API 会增加引用生命周期、权限和后端存储语义。逐次运行的 `stdoutMaxBytes` 请求是更窄的 seam:搜索要么在 `rawOutputMaxBytes` 内收到完整 stdout,要么明确失败。
+
+**始终注册,只有执行时才报告缺少 `rg`。** 不采用:模型可见工具 schema 是部署能够尝试该能力的承诺。如果 bash 执行器在加载时找不到 ripgrep,更安全的接口是完全没有 `glob`/`grep` 工具或提示词指引。对于注册后发生环境变化的情况,执行时的 `rg` 缺失分类仍作为防御性回退。
+
+## 测试
+
+- 测试覆盖注册时 `rg` 探测(探测成功会注册两个工具和提示词章节;非零探测会跳过工具与提示词章节并发出警告;基础设施探测失败会拒绝插件加载),证明中止的 `exec.signal` 会到达 bash 后端(通过同一引用的 spec 断言和 `SEARCH_ABORTED` 结果),并覆盖命令构造/引用(恶意模式、带空格路径、以短横线开头的值、引号、换行、glob 元字符:既有单元断言,也针对每个恶意值执行真实 `bash -c` 往返)、将 `grep.path` 用作文件与目录目标、将 `glob.path` 用作目录搜索根、无效模式处理、无匹配、格式错误的 `rg --json` 输出、匹配行预览截断、原始输出溢出、超时/中止、格式化落盘成功/失败、包自有 `SEARCH_*` 错误代码,以及无后台任务不变量。
+- 直接覆盖第一方工具自有落盘先例:落盘后端存在、落盘后端缺失、`saveText()` 失败,以及缺少落盘所有者。
+- 该包通过真实 Loader 路径覆盖命名空间插件的导出形状(`name`、`inject`、`Config` 和 `apply`,且没有默认导出)。
+- 真实执行器集成测试(`dsh-bash-local` + 真实 `rg`)验证外部世界:恶意模式保持惰性、逐会话 cwd 解析、VCS 元数据排除、按修改时间排序,以及真实 ripgrep stderr 分类。如果测试进程的 PATH 中没有 `rg`,该测试会自行跳过(这是与无密钥 e2e 跳过相似的 CI 兼容措施);伪执行器测试覆盖注册和执行时缺少 `rg`,并由逐文件 100% 覆盖率门禁兜底。
+- transcript 可见落盘通知仍有快照缺口:此功能合入时记录了缺口说明,没有快照。快照层会回放 acp-agent 树;在其中加入搜索插件会改变组装的系统提示词,必须使用真实密钥重新录制每一份预期输出,而实现环境没有密钥。落盘通知的确切 transcript 文本由单元测试固定(`formatGlobOutput`/`formatGrepOutput` 以及通过注册表执行的落盘测试);下一次拥有密钥的会话应把插件接入 acp-agent 树,并运行一次 `test:snapshot:record`。
+
+## 后果
+
+- `glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,不是 `ctx.fs` 提供方方法,也不属于现有 `@deepseek-ai/dsh-tool-fs` 根插件。只有 bash 执行器能找到 `rg` 时才会注册;该包注入 `tools`、`systemPrompt` 和 `bash`,不注入 `fs`,并使 `ctx.spillStore` 保持可选,读取时使用 `ctx.get('spillStore')`。
+- Schema 严格为 `glob(pattern, path?)` 和 `grep(pattern, path?, include?)`;搜索上限与超时是带默认值并经过验证的 Config 字段(`globMaxResults`、`grepMaxMatches`、`grepMaxLineBytes`、`rawOutputMaxBytes`、`timeoutMs`)。
+- 工具通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 执行,转发 `exec.signal`,绝不调用 `ctx.bash.start()`,也绝不公开 bash task id。如果存在 `exec.agent?.session.header.cwd`,bash 请求 workdir 来自该值;解析后的 `spec.workdir` 决定执行与相对路径显示。
+- 工具向 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,只解析上限内未截断的 stdout,并将超限或仍被截断的原始输出视为明确的搜索失败;绝不向模型公开原始 `rg` 输出。
+- 只要可用,过大的完整格式化结果会通过 `ctx.spillStore.saveText()` 保存,而内联结果保持有界;落盘失败、后端缺失或所有者缺失时,系统保留内联结果并报告未保存的剩余内容,绝不会返回 `isError`。
+- 包 README、生成的配置目录与导出 JSDoc 会记录 Config 字段和 `SEARCH_*` 代码;tui-agent 示例会提供条件式工具插件(acp-agent 树等待完成上述快照重新录制);fs 组 README 会记录 `rg` 可用性以及 bash/文件系统共置部署要求。
+
+## 风险
+
+在宽泛模式下,完整运行的 `grep` 可能比提前停止搜索更慢。v1 为了简化实现并恢复完整结果而接受这项成本,同时通过工具超时、bash 超时、`rawOutputMaxBytes` 和输出上限加以约束。如果实际运行过慢,仍可采用直接 ripgrep 或前台流式替代方案。
+
+Shell 命令构造是最尖锐的安全边界。`ctx.bash` 接受命令字符串而不是 argv 向量,因此实现必须集中处理 shell 引用,并测试恶意模式、带空格路径、以短横线开头的模式、引号、换行和 glob 元字符。
+
+v1 假设 bash 与文件系统共置部署。如果 bash 搜索一个工作区,而 `read` 工具基于另一个根解析路径,返回路径可能无法继续读取。该包会记录这项要求,但不在运行时验证。
+
+落盘定位符由后端负责。当前本地后端返回本地文件系统路径,适用于 `read`/`grep` 能打开这些文件的部署;远程或工作区受限部署可以使用另一种后端,让其定位符和检索提示指向受支持的检索机制。
diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml
new file mode 100644
index 0000000000..2e7b5b1d58
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c
+2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac
diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
index d4c5154ed2..a55bf276bf 100644
--- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
+++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md)
+
## Problem
An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md
new file mode 100644
index 0000000000..84b8b22187
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md
@@ -0,0 +1,87 @@
+# Agent Note: 向工具与钩子公开 agent(智能体)会话标识和 JSONL 位置
+
+Status: implemented
+
+[English](2026-07-10-agent-session-identity-and-log-location.md) | 中文
+
+## 问题
+
+agent 可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。
+
+这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。
+
+## 决策
+
+在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam 上增加同步、无副作用的位置查询:
+
+```ts
+import type { SessionHeader } from '@deepseek-ai/dsh-session'
+
+interface SessionLocation {
+ readonly kind: string
+ readonly path: string
+}
+
+interface SessionPersistence {
+ locate(meta: SessionHeader): SessionLocation | undefined
+}
+```
+
+`path` 是指向该后端为 `meta` 保留的专用日志的绝对本地路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告延迟创建的目标路径。
+
+面向模型的 bash 包(package)拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。
+
+注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层:
+
+- `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-paths`](../../../../packages/util/paths/README.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。
+- `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。
+- 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。
+- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。
+
+会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。
+
+bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。
+
+bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。
+
+[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。
+
+## 同类产品调研
+
+同类产品把稳定标识与物理存储分开处理。Codex 向 spawn 的 shell 注入稳定的 `CODEX_THREAD_ID`,而 recorder 和钩子接口负责提供 transcript 路径。Claude Code 通过结构化的钩子/状态输入提供 `session_id` 和 `transcript_path`。OpenCode 在结构化工具上下文中携带标识;Kimi Code 展开会话占位符;Reasonix 则把活动会话路径保存在控制器上。可移植的规则是:在调用边界注入标识,由存储层解析位置,绝不在并发 harness 中使用进程级的当前会话全局变量。
+
+## 生命周期与持久化语义
+
+新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。
+
+恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。
+
+`dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。
+
+## 测试
+
+单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器契约测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。
+
+一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照覆盖会锁定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。
+
+## 考虑过的替代方案
+
+**只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。
+
+**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。
+
+**使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。
+
+**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。
+
+**使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。
+
+**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。
+
+**增加面向模型的 `session_info` 工具。** bash 已经提供查询接口,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。
+
+## 影响
+
+每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。
+
+该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
new file mode 100644
index 0000000000..0c7363827e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1
+2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
index 4904da5bd5..c67ae61939 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-10-parallel-tool-call-execution.zh.md)
+
## Problem
An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together.
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
new file mode 100644
index 0000000000..a80317aa95
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
@@ -0,0 +1,103 @@
+# Agent Note: 按单次调用安全性并行执行工具调用
+
+Status: implemented
+
+[English](2026-07-10-parallel-tool-call-execution.md) | 中文
+
+## 问题
+
+一条 assistant 消息可以包含多个并列的 `tool-call` 块。尽管模型已经同时请求了这些调用,串行执行仍会叠加各个独立读取和 Web 请求的延迟。
+
+并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。
+
+会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。
+
+## 决策
+
+每个工具都可以提供可选的 `isConcurrencySafe(args)` 分类器。该分类器必须是同步纯函数:它只检查当前调用已解析的参数,不执行 I/O 或任何变更。只有显式返回 `true` 才表示选择并行;分类器缺失、参数无效、分类器抛错或返回任何其他值,都会使该调用按独占方式执行。规范类型契约见[工具数据结构](../../../../docs/core-data-structures/tools.md)。
+
+分类器有意设计为一元函数。返回 `true` 表示工具承诺:此调用可以与任何同样返回 `true` 的并列调用重叠执行。调度器不会比较调用,也不会证明它们的资源访问相容。
+
+这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。
+
+`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将以安全方式退化为独占。
+
+使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器契约。
+
+## 调度与顺序
+
+循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。
+
+例如:
+
+```text
+[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]
+
+→ [read(A), read(B)]
+→ [write(A)]
+→ [read(C)]
+```
+
+`read(A)` 和 `read(B)` 可以重叠执行。`write(A)` 要等两者都完成后才启动,`read(C)` 则要等写入完成后才启动。
+
+每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。
+
+只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行,因此必须能在不同执行之间重入。
+
+每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
+
+如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。
+
+Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
+
+## 安全契约
+
+工具返回 `true` 即承诺:其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态;它将输出返回给循环,由循环按模型顺序提交。
+
+执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方:它们可以在内部串行化,也可以实施自身容量限制,但必须在并发派发时不破坏状态。
+
+## 配置与声明
+
+`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。
+
+当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
+
+文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。
+
+## 验证
+
+单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。
+
+快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
+
+## 备选方案
+
+**保持串行执行。** 这可以避免新的顺序和中止情形,但会保留独立并列调用所产生的不必要延迟。
+
+**使用一个工具级布尔值。** 固定的 `supportsParallelToolCalls` 标志更小,但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。
+
+**使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。
+
+**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于关系时安全退化。
+
+**并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。
+
+**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。
+
+**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
+
+**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。
+
+**向模型暴露并发元数据。** 模型已可以发出并列调用。宿主调度元数据会扩大请求,却无助于工具选择。
+
+## 影响
+
+该设计以安全退化为原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
+
+在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
+
+有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
+
+并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
+
+工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml
index da1ff70e09..d4b48d4f41 100644
--- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml
@@ -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
-2026-07-10-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9
+2026-07-10-sqlite-session-query-provider.md: 98618a7eb572ce59c5fa5984675c9dc57b3f4289
2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709
diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
index be8795b609..98618a7eb5 100644
--- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
+++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
@@ -34,13 +34,13 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim
The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation.
-One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries.
+One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It passes the caller's exact abort signal into snapshot listing and non-mutating inspection, directly awaits every started backend operation, and checks cancellation after each await and before starting more work. Cancellation therefore rejects only after active backend work is quiescent, starts no subsequent observation or reconciliation step, and keeps a following search serialized behind cleanup even if a backend ignores the signal. The operation never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries.
Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources.
The derived schema has its own application id and monotonic schema version. Persistent and TEMP session metadata store the integer `SessionHeader.createdAt` contract in strict `INTEGER` columns. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
-Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption.
+Cancellation rejects queued operations promptly. Once asynchronous source observation starts, the caller waits for that backend promise to settle before rejection, without committing an aborted observation or starting more source/index work. Node's synchronous `DatabaseSync` metadata and MATCH calls cannot be interrupted once executing on the JavaScript thread, so the service checks the signal around those calls but does not promise mid-statement preemption.
## Alternatives considered
@@ -54,6 +54,6 @@ Cancellation rejects queued operations and caller waits around asynchronous sour
Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable.
-The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section.
+The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is prompt while queued and quiescent while awaiting sources; synchronous SQLite execution remains a non-preemptible section bracketed by signal checks.
Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml
similarity index 64%
rename from .agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml
rename to .agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml
index 737a9da6ca..ca8877d5a7 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.i18n.yaml
@@ -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
-2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08
-2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d
+2026-07-13-session-query-tracing.md: 47c12824a331546676d3bc79920f861afe648431
+2026-07-13-session-query-tracing.zh.md: 485060f9e57b5644f7b364e2120bfe30607b1945
diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md
index 08f856863e..47c12824a3 100644
--- a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md
+++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-13-session-query-tracing.zh.md)
+
## Problem
Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning.
diff --git a/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md
new file mode 100644
index 0000000000..485060f9e5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-13-session-query-tracing.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: 会话查询关系追踪
+
+Status: implemented
+
+[English](2026-07-13-session-query-tracing.md) | 中文
+
+## 问题
+
+会话关系分散编码在不可变 header、位置式表面操作和已记录的来源数组中。消费方如果直接重建这些关系,就必须重复实现语料优先级、表面折叠、格式错误日志的处理、确定性的谱系顺序和克隆。位置替换与来源属于不同的图,因此把两者合并为一种通用边类型也会丢失含义。
+
+## 决策
+
+`ctx.sessionQuery` 除精确读取外,还公开 `traceSession(sessionId)` 和 `traceEvent({ sessionId, seq })`。两者都是基于现有「实时数据优先」语料的一次性视图:会话追踪读取一次完整语料列表,事件追踪读取一份逻辑日志并执行一次规范表面折叠。服务在调用结束后不会保留谱系、反向索引或替换状态。
+
+`SessionLineageTrace` 返回目标、按从直接父级到外层父级排序的已知父级,以及递归的后代树;同级节点先按创建时间排序,再按 session id 排序。`complete: true` 会携带已知根节点;`complete: false` 会携带第一个无法解析的父级 id。与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。
+
+`SessionEventTrace` 将位置关系与来源关系分开保留。`replacedBy` 是直接的位置替换者,`replacementChain` 沿替换者追踪至最终节点,`replacedEventSeqs` 则列出目标直接移除的真实表面节点。`sourceEventSeqs` 保留日志中直接来源的顺序,而 `derivedEventSeqs` 按日志顺序列出后续的直接反向引用。来源关系不会传递展开。
+
+## 校验边界
+
+事件追踪会在分析表面之前检查目标是否存在。随后,事件列表与追踪都会使用 `dsh-session` 的单遍表面折叠,对加载的日志整体进行接受或拒绝:事件 seq 从零开始且连续;表面标记符合事件类型的适用范围;只有表面事件类型可以携带来源;存在的数组必须非空且没有重复项;每个来源必须是更早的 seq;每次位置替换必须指明并引用它所移除的全部表面节点。任何契约违例都使用 `SESSION_QUERY_INVALID_SURFACE`;系统不存在只用于分类、要求更弱的表面标准。
+
+所有返回的记录与数组都与内部状态分离。已知的实时事件追踪绝不查询持久化;持久化事件追踪保留精确读取所要求的列表/加载一致性检查。会话谱系必然属于跨语料操作,因此也保留跨语料的持久化失败语义。
+
+## 考虑过的替代方案
+
+- **公开独立的追踪辅助函数**:不予采纳,因为源优先级与状态分离边界属于 `ctx.sessionQuery`;公开辅助函数会诱使调用方绕过该边界。
+- **合并替换边与来源边**:不予采纳,因为位置替换可以遮蔽表面节点,同时引用不在表面上的构造输入,而消费方需要区分这两种含义。
+- **返回传递来源闭包**:不予采纳,因为这会掩盖日志中直接记录的证据、增大结果,并让一条遥远的格式错误边改变原本局部的输出。
+- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查必须快速失败。
+
+## 影响
+
+消费方无需缓存或引入第二份语料,即可获得确定性的关系视图。事件追踪每次调用都会执行全日志校验和分配,而谱系追踪每次调用都会列出完整的逻辑语料。这些成本让真源保持明确,并且与承载内容的全文搜索及过滤 API 相互独立。
+
+该功能具备单元测试和服务层覆盖率,但没有快照或端到端 fixture(测试前置数据),因为它没有引入面向模型的消费方、transcript(文本记录)变更或跨进程协议。
diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md
deleted file mode 100644
index 189f75fc12..0000000000
--- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Agent Note: Optional time-context plugin
-
-Status: implemented
-
-English | [中文](2026-07-14-time-context-plugin.zh.md)
-
-## Problem
-
-The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract.
-
-An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message.
-
-Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle.
-
-## Decision
-
-`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-spine-demo` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable.
-
-The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section.
-
-### Previous-message baseline
-
-At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`.
-
-The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero.
-
-### Refresh policy
-
-`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound.
-
-When `timeZone` is omitted, `Intl.DateTimeFormat` resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone.
-
-### Logging and token shape
-
-The loop records the temporal block in full `request/header` snapshots before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case.
-
-## Testing
-
-Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and full `request/header` snapshots. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block.
-
-## Alternatives considered
-
-- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation.
-- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock.
-- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging.
-- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template.
-- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing.
-- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it.
-- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either.
-- **Mount the plugin in `dsh-agent-spine-demo`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable.
-- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key.
-
-## Consequences
-
-- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session.
-- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user.
-- A refresh changes the request header and can add a full `request/header` snapshot with reason `change`. `refreshIntervalMs` trades freshness against the number and size of durable full snapshots; `0` records a new value on every step whose whole-second rendering changes.
-- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles.
-- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract.
diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md
deleted file mode 100644
index 12671cb891..0000000000
--- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Agent Note:可选时间上下文插件
-
-Status: implemented
-
-[English](2026-07-14-time-context-plugin.md) | 中文
-
-## 问题
-
-本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 Agent Note 负责当前的模型可见与持久性契约。
-
-如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。
-
-提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。
-
-## 决策
-
-`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该包;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
-
-该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。
-
-### 上一条消息基线
-
-在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。
-
-基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。
-
-### 刷新策略
-
-`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。
-
-省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。
-
-### 日志与 token 形态
-
-agent loop(智能体循环)会在发送前通过完整的 `request/header` 快照记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。
-
-## 测试
-
-单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和完整的 `request/header` 快照。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。
-
-## 考虑过的替代方案
-
-- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。
-- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。
-- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。
-- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。
-- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。
-- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。
-- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。
-- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。
-- **将包放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
-
-## 后果
-
-- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。
-- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。
-- 刷新会改变请求头,并可能新增一份 reason 为 `change` 的完整 `request/header` 快照。`refreshIntervalMs` 用新鲜度换取完整持久快照的数量与大小;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。
-- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。
-- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。
diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml
index f037660761..910872881a 100644
--- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml
@@ -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
-2026-07-16-durable-per-step-time-context.md: 2d7076d51dbe1a64e5042230bddc6844141ff265
-2026-07-16-durable-per-step-time-context.zh.md: 432e0305cf44dcce1053c6580c9f0039309a7af4
+2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e
+2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf
diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md
index 2d7076d51d..4bc17b3c08 100644
--- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md
+++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md
@@ -12,13 +12,13 @@ A process-local refresh cache makes displayed time depend on state that cannot s
## Decision
-`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
+`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback.
The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone.
-The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
+The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
### Text and elapsed baselines
@@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1:
Elapsed since the preceding model-visible message: .
```
-The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
+The baseline is the latest preceding user, assistant, tool-result, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
An injected later-step reading is:
@@ -48,11 +48,7 @@ The plugin contributes nothing to system-prompt assembly. `request/header` conta
## Testing
-Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally.
-
-## Supersedes
-
-This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement.
+Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader with the Headless composition, drives two ordered one-shot turns, and verifies the persisted plugin-attributed messages externally.
## Alternatives considered
@@ -61,10 +57,13 @@ This decision supersedes the dynamic system-prompt storage and refresh policy in
- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing.
- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step.
- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings.
+- **Mutate assembled requests or register independent prompt variables** — rejected because request-local insertion bypasses the durable surface and separate providers can sample different instants. One attributed context message records the timestamp and elapsed baseline atomically.
+- **Default to UTC or add a time-zone detection dependency** — rejected because an explicitly mounted plugin follows its process environment unless the operator selects an IANA zone, while no server-side library can infer a remote user's zone.
+- **Mount the plugin in shipped compositions or place it in `core/`** — rejected because disclosure, time zone, freshness, and history cost are deployment choices for an optional context leaf, not product-spine policy.
## Consequences
- Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume.
- Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure.
- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context.
-- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps.
+- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. Supporting client-origin time requires a separate durable input contract.
diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md
index 432e0305cf..836c0f83fb 100644
--- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md
@@ -12,13 +12,13 @@ Status: implemented
## 决策
-`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
+`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。
省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。
-插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
+插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `user/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
### 文本与时长基线
@@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1:
Elapsed since the preceding model-visible message: .
```
-基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
+基线是前一条用户消息、助手消息、工具结果或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
后续步骤的注入读数为:
@@ -48,11 +48,7 @@ Elapsed since the preceding step context: .
## 测试
-单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
-
-## 取代的决策
-
-本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。
+单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 Loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。
## 考虑过的替代方案
@@ -61,10 +57,13 @@ Elapsed since the preceding step context: .
- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。
- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。
+- **修改已组装的请求或注册独立提示词变量**——不予采纳,因为请求内插入会绕过持久表层,不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。
+- **默认使用 UTC 或增加时区检测依赖**——不予采纳,因为显式挂载的插件默认遵循其进程环境,除非操作方选择 IANA 时区,而任何服务端库都无法推断远程用户的时区。
+- **在已交付组合中挂载插件,或把它放进 `core/`**——不予采纳,因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择,不是产品主干策略。
## 后果
- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。
- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
-- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。
+- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间,需要另行建立持久输入契约。
diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md
deleted file mode 100644
index a2a22baafd..0000000000
--- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Agent Note: Startup slogans replace the configured TUI welcome line
-
-Status: implemented
-
-English | [中文](2026-07-20-tui-startup-slogans.zh.md)
-
-> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand.
-
-## Problem
-
-The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption.
-
-## Decision
-
-- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs).
-- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners.
-- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character.
-- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle.
-- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text.
-
-The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write).
-
-## Alternatives considered
-
-**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost.
-
-**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy.
-
-**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it.
-
-## Consequences
-
-- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead.
-- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`.
-- Adding a slogan is a one-line bank edit; tests assert membership, not specific text.
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan).
diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md
deleted file mode 100644
index 58fa5790f3..0000000000
--- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语
-
-Status: implemented
-
-[English](2026-07-20-tui-startup-slogans.md) | 中文
-
-> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。
-
-## Problem
-
-TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。
-
-## Decision
-
-- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。
-- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。
-- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。
-- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。
-- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。
-
-同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。
-
-## Alternatives considered
-
-**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。
-
-**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。
-
-**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。
-
-## Consequences
-
-- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。
-- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。
-- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。
diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml
index f1b9829b73..2c0b4d3404 100644
--- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml
@@ -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
-2026-07-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b
-2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43
+2026-07-21-dsh-system-prompt-source-path.md: 4cb89e8124840bba6633235d195e95957245137c
+2026-07-21-dsh-system-prompt-source-path.zh.md: 90c23bed4a3f95155e323c63a68fe2da09543ea6
diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md
index b54d01488f..4cb89e8124 100644
--- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md
+++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md
@@ -16,7 +16,7 @@ The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*`
## Scope
-Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
+Only the `dsh` CLI adds this. The demo bins (`dsh-cli-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
## HMR
diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md
index 208e3dce07..90c23bed4a 100644
--- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md
@@ -16,7 +16,7 @@ Status: implemented
## Scope
-只有 `dsh` CLI 会加入这一段。demo bin(`dsh-tui-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
+只有 `dsh` CLI 会加入这一段。demo bin(`dsh-cli-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
## HMR
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md
deleted file mode 100644
index 069fd33a88..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Agent Note: Auto-titled terminal from the first message
-
-Status: implemented
-
-English | [中文](2026-07-21-tui-auto-pane-title.zh.md)
-
-> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
-
-> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand.
-
-## Problem
-
-The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing.
-
-## Decision
-
-- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback.
-- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis).
-- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes.
-- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown.
-- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop.
-- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message.
-
-## Alternatives considered
-
-**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title.
-
-**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever.
-
-**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert.
-
-**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency.
-
-**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn.
-
-## Consequences
-
-- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing.
-- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay.
-- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted.
-- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md
deleted file mode 100644
index 580f36b256..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Agent Note: 从首条消息自动命名终端
-
-Status: implemented
-
-[English](2026-07-21-tui-auto-pane-title.md) | 中文
-
-> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
-
-> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。
-
-## Problem
-
-TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。
-
-## Decision
-
-- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`)。开启后,TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。
-- 该标签是模型概括,而非对提示词的截断。请求携带一段固定的任务指令(将该请求概括为两到五个小写单词、不含标点的简短标题)加上用户的首条消息,且不带工具;TUI 取回复的首个非空行并截断到 40 个字符(39 个字符加一个省略号)。
-- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。
-- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider` 或 `model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。
-- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript(文本记录),也无法扰动 agent loop(智能体循环)。
-- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture(测试前置数据)中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。
-
-## Alternatives considered
-
-**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。
-
-**重命名窗口(OSC 2)或 tmux 窗口。** 否决:OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。
-
-**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。
-
-**并入日志支撑的会话标题工作(PR #451)。** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。
-
-**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。
-
-## Consequences
-
-- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token;默认关闭时它不产生任何开销。
-- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。
-- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。
-- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息,因此它们从不触发该调用。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml
deleted file mode 100644
index 830ca3e2e0..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22
-2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md
deleted file mode 100644
index 35809e1ef6..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Agent Note: Auto-title on by default, re-derived on resume
-
-Status: implemented
-
-English | [中文](2026-07-21-tui-auto-title-default-on.zh.md)
-
-> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
-
-## Problem
-
-The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience.
-
-## Decision
-
-- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run.
-- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored.
-- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead.
-- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt).
-
-## Alternatives considered
-
-**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment.
-
-**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation.
-
-**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on.
-
-## Consequences
-
-- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected.
-- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged.
-- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default".
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md
deleted file mode 100644
index aa20cfde13..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Agent Note: 自动标题默认开启,恢复时重新推导
-
-Status: implemented
-
-[English](2026-07-21-tui-auto-title-default-on.md) | 中文
-
-> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
-
-## Problem
-
-[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。
-
-## Decision
-
-- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。
-- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。
-- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。
-- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。
-
-## Alternatives considered
-
-**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。
-
-**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。
-
-**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。
-
-## Consequences
-
-- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。
-- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。
-- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md
deleted file mode 100644
index c146424d53..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Agent Note: The banner sweeps in; the subtitle line is gone
-
-Status: implemented
-
-English | [中文](2026-07-21-tui-banner-sweep.zh.md)
-
-> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it.
-
-## Problem
-
-The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand.
-
-## Decision
-
-- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation.
-- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole.
-- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes.
-
-## Alternatives considered
-
-**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface.
-
-**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration.
-
-**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences.
-
-## Consequences
-
-- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged.
-- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them.
-- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text.
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md
deleted file mode 100644
index 01cc153e88..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Agent Note: 横幅整体扫入;副标题行移除
-
-Status: implemented
-
-[English](2026-07-21-tui-banner-sweep.md) | 中文
-
-> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。
-
-## Problem
-
-[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。
-
-## Decision
-
-- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。
-- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。
-- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。
-
-## Alternatives considered
-
-**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。
-
-**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。
-
-**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。
-
-## Consequences
-
-- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。
-- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。
-- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml
index 8732101ab2..5d3ddbd972 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml
@@ -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
-2026-07-21-tui-borderless-banner.md: 37263854b6cc77283215c3c1378f9908ff966611
-2026-07-21-tui-borderless-banner.zh.md: ca796e49cb9d3a9abc0acd64a39448bc3f9ad50e
+2026-07-21-tui-borderless-banner.md: 2fcb414c11f91df0914b17aa973e45746bbdfc67
+2026-07-21-tui-borderless-banner.zh.md: 8f80b21e6425bb38fff52529f1df8d262c34338f
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md
index 37263854b6..2fcb414c11 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md
@@ -6,34 +6,41 @@ English | [中文](2026-07-21-tui-borderless-banner.zh.md)
## Problem
-The [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion.
+An intermediate no-banner design removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion.
## Decision
-- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn.
-- The header carries the title (`DEEPSEEK HARNESS`), a ` • ` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only.
-- The model **also** stays in the footer's left segment. The no-banner note's footer model prefix is kept, not reverted, so the driving model stays glanceable after the transient banner scrolls out of view.
+- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. The reveal advances through about 24 frames at 15 ms each.
+- The header carries the title (`DEEPSEEK HARNESS`), a ` • ` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only: there is no fixed or random slogan.
+- The model **also** stays in the footer's left segment, so the driving model remains glanceable after the transient banner scrolls out of view.
- `welcome` reverts to a banner subtitle; the transcript-first-line notice is removed from `rebuildTranscript`.
- The sweep animates only when `welcome` is unset. A configured `welcome` renders the whole banner immediately, keeping fixtures and snapshots frame-deterministic. The sweep starts after `ui.start()` succeeds and is cleared through the same `detachListeners` path via `stopBannerReveal`, which also resets the clip so a header disposed mid-sweep re-renders whole.
-This supersedes the [no-banner Agent Note](2026-07-21-tui-no-banner.md) (which superseded the [banner-sweep Agent Note](2026-07-21-tui-banner-sweep.md)): the banner and its sweep return borderless, while the model's footer home the no-banner note added stays.
+This note owns the current result of the discarded startup variants: random slogans with a per-character typewriter, a boxed whole-banner sweep, and no banner. The example composition does not set `welcome`; deployments and deterministic fixtures may still provide one. The model's persistent footer home from the no-banner variant remains.
## Alternatives considered
**Keep the box but thin it or use lighter glyphs.** Rejected: the instruction was "just remove the border"; any surrounding glyph is the frame chrome the user objected to.
-**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session — the reason the no-banner note put it there, deliberately preserved.
+**Keep a random or fixed slogan when `welcome` is unset.** Rejected because repeated flavor copy becomes wallpaper and the per-character reveal was slow while animating only one line. An unset welcome therefore produces no subtitle, and the whole banner supplies the startup motion.
-**Leave the session id out, as the no-banner note decided.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`.
+**Remove the banner entirely.** Rejected because the persistent footer is a good home for the model but not for the full identifying detail, while putting `welcome` in the transcript makes presentation configuration behave like conversation content.
+
+**Reveal the banner top-down.** Rejected because four row-sized steps read as a flicker. The horizontal width clip uses the terminal span for smooth motion and reuses the ANSI-aware truncation path.
+
+**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session; that persistent location is deliberately preserved.
+
+**Leave the session id out of the banner.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`.
## Consequences
- Boot output with `welcome` unset is animation-dependent again (the sweep); configured welcomes stay frame-deterministic, so every snapshot and scripted fixture keeps a fixed subtitle.
+- The demo no longer supplies instructional welcome filler; an unset `welcome` means a subtitle-free banner, while the config remains the deterministic escape hatch for deployments and fixtures.
- The model now appears twice at boot — banner detail and footer — intended redundancy: the banner is transient, the footer persistent.
-- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike the no-banner welcome line that `/clear` wiped.
+- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike a transcript-based welcome line.
- All pi-tui terminal snapshots and the examples/tui-agent replay snapshots re-recorded (`test:snapshot:refresh`): banner rows return with no box glyphs; footer rows keep the model prefix.
- Anything that anchored on banner absence re-anchors on its presence: the PTY smoke boots on the detail line's `main-session-` id (revealed late in the sweep) and asserts `DEEPSEEK`/`HARNESS` present with no box corners.
## Testing
-`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames.
+`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. Independent color-scheme cases cover reported light/dark transitions, a same-scheme no-op, and a terminal that throws on the DSR query write; `applyColorScheme` relies on `setStatus` to rederive the editor border instead of repeating the dead assignment that had broken per-file coverage. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md
index ca796e49cb..8f80b21e64 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md
@@ -6,34 +6,41 @@ Status: implemented
## Problem
-[移除横幅 Agent Note](2026-07-21-tui-no-banner.md) 删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。
+一个中间的无横幅设计删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。
## Decision
-- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。
-- 头部承载标题(`DEEPSEEK HARNESS`)、一条 ` • ` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情。
-- 模型**同时**保留在页脚的左段。移除横幅那版 note 加入的页脚模型前缀被保留而非回退,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。
+- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。扫入大约经过 24 帧完成,每帧间隔 15 ms。
+- 头部承载标题(`DEEPSEEK HARNESS`)、一条 ` • ` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情:不含固定或随机标语。
+- 模型**同时**保留在页脚的左段,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。
- `welcome` 恢复为横幅副标题;transcript 第一行的通知从 `rebuildTranscript` 中移除。
- 仅当 `welcome` 未设置时才播放扫入动画。配置了 `welcome` 会立即渲染整个横幅,使 fixture 和快照保持帧确定性。扫入在 `ui.start()` 成功后启动,并经与之前相同的 `detachListeners` 路径通过 `stopBannerReveal` 清理;后者还会重置裁剪,使扫入中途被销毁的头部重新完整渲染。
-本 note 取代[移除横幅 Agent Note](2026-07-21-tui-no-banner.md)(后者取代了[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)):横幅及其扫入动画以无边框方式回归,而移除横幅那版 note 为模型设立的页脚归宿得以保留。
+本 Agent Note 统一记录几种已弃用启动方案的当前结论:带逐字打字机效果的随机标语、带边框的整幅横幅扫入动画,以及完全移除横幅。示例组装不设置 `welcome`;部署和确定性 fixture 仍可提供该值。无横幅方案为模型设置的常驻页脚位置继续保留。
## Alternatives considered
**保留盒子但做细或改用更轻的字符。** 否决:指令是 "just remove the border";任何环绕的字符都是用户所反对的边框装饰。
-**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见——这正是移除横幅那版 note 把它放在那里的原因,此处刻意保留。
+**在未设置 `welcome` 时保留随机或固定标语。** 否决:反复出现的氛围文案很快失去信息价值,而逐字揭示仅为一行制作动画,速度又慢。因此,未设置 `welcome` 时不显示副标题,由整个横幅提供启动动效。
-**像移除横幅那版 note 那样,把会话 id 留在外面。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。
+**完全移除横幅。** 否决:常驻页脚很适合显示模型,却无法承载完整识别详情;把 `welcome` 放入 transcript 还会使展示配置表现成对话内容。
+
+**自上而下揭示横幅。** 否决:按四行分成四步看起来像闪烁。横向宽度裁剪利用终端横向空间实现平滑动效,并复用 ANSI 感知的截断路径。
+
+**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见;这个常驻位置被刻意保留。
+
+**将会话 id 留在横幅之外。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。
## Consequences
- `welcome` 未设置时的启动输出再次依赖动画(扫入);配置了欢迎语则保持帧确定性,因此每个快照和脚本 fixture 都保留一个固定副标题。
+- demo 不再提供教学性质的欢迎填充文案;`welcome` 未设置就表示横幅没有副标题,而该配置仍是部署和 fixture 获得确定性输出的配置手段。
- 模型现在在启动时出现两次——横幅详情与页脚——这是有意的冗余:横幅短暂,页脚常驻。
-- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于被 `/clear` 清掉的移除横幅那版的欢迎行。
+- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于基于 transcript 的欢迎行。
- 全部 pi-tui 终端快照与 examples/tui-agent 回放快照重新录制(`test:snapshot:refresh`):横幅行以无盒子字符方式回归;页脚行保留模型前缀。
- 一切锚定横幅缺失的内容改为锚定其存在:PTY 冒烟测试以详情行的 `main-session-` id 为启动标记(它在扫入后段才被揭示),并断言 `DEEPSEEK`/`HARNESS` 出现且无盒子角。
## Testing
-`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。
+`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。独立的配色方案用例覆盖终端报告的浅色/深色转换、相同方案下的空操作,以及写入 DSR 查询时抛出异常的终端;`applyColorScheme` 依靠 `setStatus` 重新推导编辑器边框,而不再重复那个导致逐文件覆盖率未达标的无效赋值。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md
deleted file mode 100644
index f5f4b1b847..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Agent Note: No startup banner
-
-Status: implemented
-
-English | [中文](2026-07-21-tui-no-banner.zh.md)
-
-> **Superseded** by the [borderless-banner Agent Note](2026-07-21-tui-borderless-banner.md): the banner and its sweep return without the box. The model's footer home this note added stays.
-
-## Problem
-
-The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes.
-
-## Decision
-
-- `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator.
-- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there.
-- `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length.
-
-This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone.
-
-## Alternatives considered
-
-**Keep a one-line header (no box).** Rejected: the only load-bearing fact was the model name, and the footer already aggregates session status; a dedicated header row for one fact is the same chrome, smaller.
-
-**Show the session id in the footer too.** Rejected: a 36-char UUID dominates the 100-column footer and clips the status segment; it identifies the session for resume, which is a log/filesystem concern, not a glanceable one.
-
-**Print the welcome outside the transcript (above the separator).** Rejected: any fixed region above the transcript is a banner again; as a transcript line it scrolls away naturally and survives rebuilds through the same path as every other transcript element.
-
-## Consequences
-
-- Startup output is fully deterministic again — no animation frames at all; the interval-lifecycle machinery from the two animation iterations is gone.
-- All 26 pi-tui terminal snapshots re-recorded (`test:snapshot:refresh`): banner rows gone, footer rows gain the model prefix.
-- Anything that anchored on banner text (`DEEPSEEK`, box corners) re-anchors on the footer model name; `main-session-` no longer appears in boot output.
-- `/clear` now wipes the welcome line too: it is an ordinary transcript line, and `/clear` empties the transcript (the old banner survived `/clear` only by sitting outside it).
-- The footer's left segment is wider; on narrow terminals the right status segment clips earlier.
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` pins: no box corners/product title and an empty transcript when `welcome` is unset, with the model in the footer; a configured welcome as the first transcript line without a banner; and the welcome surviving a palette-swap transcript rebuild. The PTY smoke boots on the footer model name and asserts `DEEPSEEK HARNESS` is absent. Snapshots verify the full frames.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md
deleted file mode 100644
index 956fe03e2c..0000000000
--- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Agent Note: 移除启动横幅
-
-Status: implemented
-
-[English](2026-07-21-tui-no-banner.md) | 中文
-
-> **已被取代**,见[无边框横幅 Agent Note](2026-07-21-tui-borderless-banner.md):横幅及其扫入动画回归,只是去掉了盒子。本 note 为模型设立的页脚归宿得以保留。
-
-## Problem
-
-TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。
-
-## Decision
-
-- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。
-- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。
-- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。
-
-本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。
-
-## Alternatives considered
-
-**保留单行头部(去掉盒子)。** 否决:唯一有承载价值的信息是模型名,而页脚已经聚合会话状态;为一条信息保留专用头部行仍是同一种装饰,只是小一点。
-
-**把会话 id 也放进页脚。** 否决:36 字符的 UUID 会占满 100 列页脚并裁掉状态段;它的用途是恢复会话的标识,属于日志/文件系统关注点,不是需要一瞥可见的信息。
-
-**把欢迎语渲染在 transcript 之外(分隔线上方)。** 否决:transcript 上方任何固定区域都会再次变成横幅;作为 transcript 行它自然滚走,并通过与其他 transcript 元素相同的路径在重建后保留。
-
-## Consequences
-
-- 启动输出再次完全确定——没有任何动画帧;两轮动画迭代留下的定时器生命周期机制全部移除。
-- 全部 26 个 pi-tui 终端快照重新录制(`test:snapshot:refresh`):横幅行消失,页脚行增加模型前缀。
-- 锚定横幅文本(`DEEPSEEK`、盒子角)的内容改为锚定页脚模型名;启动输出中不再出现 `main-session-`。
-- `/clear` 现在也会清掉欢迎行:它是普通的 transcript 行,而 `/clear` 清空 transcript(旧横幅能在 `/clear` 后存活只因为它在 transcript 之外)。
-- 页脚左段变宽;窄终端上右侧状态段更早被裁剪。
-
-## Testing
-
-`packages/ui/tui/tests/tui.spec.ts` 固定:`welcome` 未设置时无盒子角/产品标题、transcript 为空、模型在页脚;配置的欢迎语作为 transcript 第一行且无横幅;欢迎语在调色板切换的 transcript 重建后保留。PTY 冒烟测试以页脚模型名为启动标记并断言 `DEEPSEEK HARNESS` 不出现。快照验证完整帧。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml
index 319f28ea61..8cac245f00 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml
@@ -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
-2026-07-21-tui-verbose-status-line.md: f277afd3a874b30a29dc0ef193740f636d22290b
-2026-07-21-tui-verbose-status-line.zh.md: 9fa7cf29c67245382bbee6b72f2710c5550d7f54
+2026-07-21-tui-verbose-status-line.md: 71584ee91a911cc8652512ec26b00dae8c818f36
+2026-07-21-tui-verbose-status-line.zh.md: bda3c5e8394f7707916c6fc76045b1a6f38fa95b
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md
index f277afd3a8..71584ee91a 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md
@@ -13,7 +13,7 @@ While a turn ran, the [full-screen TUI](2026-07-17-dedicated-full-screen-tui-fro
- While a turn runs, the status line above the editor shows a derived phase label with elapsed time, keeping the trailing `— Enter sends steering, Esc cancels` hint. The four phases and their labels are `waiting` → "Waiting for the first token", `thinking` → "Thinking", `responding` → "Responding", and `executing` → "Executing tools".
- The phase is presentation state the TUI derives from live session events, not a session event or agent status of its own. `step/start` enters `waiting`; an `assistant/chunk` reasoning delta or reasoning block-start enters `thinking`; a text delta or text block-start enters `responding`; a `tool/call` enters `executing`. The event map is merge-extensible, so every other event kind falls through a default and leaves the phase unchanged.
- The label reports two clocks — ` · total ` — except `waiting`, which shows only the step total. The phase clock resets on a genuine phase change or a new step; the step clock resets on `step/start`. Durations format as `8s` below a minute and `1m05s` at or above one. Tool time between `step/end` and the next `step/start` accrues to the finishing step's total.
-- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [banner sweep](2026-07-21-tui-banner-sweep.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`.
+- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [borderless banner](2026-07-21-tui-borderless-banner.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md
index 9fa7cf29c6..bda3c5e839 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md
@@ -13,7 +13,7 @@ Status: implemented
- 轮次运行期间,编辑器上方的状态行显示一个派生的阶段标签及已用时长,并保留末尾的 `— Enter sends steering, Esc cancels` 提示。四个阶段及其标签为 `waiting` → "Waiting for the first token"、`thinking` → "Thinking"、`responding` → "Responding"、`executing` → "Executing tools"。
- 阶段是 TUI 从实时会话事件派生出的呈现状态,而非它自有的会话事件或 agent 状态。`step/start` 进入 `waiting`;`assistant/chunk` 的 reasoning 分片或 reasoning 块开始(`block-start`)进入 `thinking`;text 分片或 text 块开始进入 `responding`;`tool/call` 进入 `executing`。该事件映射可合并扩展,因此其余任何事件类型都落入默认分支,保持阶段不变。
- 标签汇报两个时钟——` · total `——但 `waiting` 只显示步骤总时长。阶段时钟在真正发生阶段切换或进入新步骤时重置;步骤时钟在 `step/start` 时重置。时长在不足一分钟时格式化为 `8s`,达到或超过一分钟时格式化为 `1m05s`。`step/end` 与下一个 `step/start` 之间的工具时间计入结束步骤的总时长。
-- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与 [banner 扫入动画](2026-07-21-tui-banner-sweep.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。
+- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与[无边框横幅](2026-07-21-tui-borderless-banner.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml
new file mode 100644
index 0000000000..9087db9dd7
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-model-facing-session-query-tools.md: 2a9a20a8b39dea309e759f4eb6ddcdabe25dd8be
+2026-07-24-model-facing-session-query-tools.zh.md: 7fbe746681b329b3e50ae74608a8b2e5167c5ae9
diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md
new file mode 100644
index 0000000000..2a9a20a8b3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md
@@ -0,0 +1,55 @@
+# Agent Note: Model-facing session query tools
+
+Status: implemented
+
+English | [中文](2026-07-24-model-facing-session-query-tools.zh.md)
+
+## Problem
+
+The unified `ctx.sessionQuery` service exposes exact reads, filters, relationship traces, and full-text search over live-preferred session logs, but models cannot use that service directly. Giving a model the provider request types would also expose unstable pagination cursors, trusted corpus scope, storage-shaped time values, and result records that are more convenient for programmatic consumers than for reasoning. Large traces and event payloads introduce a separate output-size concern, but solving that concern inside this consumer would duplicate the harness-wide spill mechanism and make session-query tools disagree with other tools.
+
+## Decision
+
+`@deepseek-ai/dsh-tool-session-query` is the model-facing consumer of `ctx.sessionQuery`. It registers five narrow read-only tools: `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. The package imports the interface rather than the SQLite implementation, owns model argument validation and readable text rendering, and contributes one concise prompt section that teaches the prior-history search and search-to-trace/read workflow.
+
+The package entrypoint is only the public composition root for configuration, prompt registration, and tool registration. Its internal modules follow the execution boundary: `input.ts` owns model schemas, normalization, and filter construction; `service-boundary.ts` contains provider calls and model-safe error translation; `workspace-access.ts` owns caller identity, workspace authorization, title access, and lineage projection; `operations.ts` orchestrates the five service workflows; and `presentation.ts` renders tool results and call cards. This keeps policy in its owning layer without changing the package contract.
+
+`session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only.
+
+Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values.
+
+## Workspace authority
+
+Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed.
+
+Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. It checks the execution signal first, preserving caller cancellation exactly. For other failures it records the available corpus or provider diagnostic chain in the internal log on a best-effort basis, substituting a fixed placeholder when the value cannot be safely inspected. Diagnostic formatting and error classification are independently guarded, so an unprintable nested cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging returns the fixed generic `SESSION_QUERY_TOOL_FAILED` code and message. Per-title failures use the same sanitizer before becoming unavailable markers. Tool-owned input-validation and authorization errors remain precise because they are created outside this service boundary.
+
+The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call.
+
+## Cursor-free results and spill
+
+Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. Because internal pages share generation-bound cursors, both search tools are exclusive in the agent-loop scheduler; the exact trace and read tools opt into parallel sibling execution because their observations tolerate intervening commits.
+
+Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. Each exact executor passes its unchanged tool-execution signal through target authorization and the service trace or read. Within service resolution, known-live event traces, event reads, and title reads remain persistence-free while honoring pre-abort. Session lineage tracing passes the signal to whole-corpus persistence listing; persisted event tracing and reading pass it to target listing and inspection. Each started backend call is awaited for cleanup before the exact abort reason is preserved, even when that backend ignored cancellation. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format.
+
+Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most the service's configured `persistedInspectConcurrency` workers, which defaults to four, and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target.
+
+## Host composition
+
+The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessionQuery` for non-model consumers, while the shared Web/headless composition mounts neither the query service nor the consumer. No shipped composition mounts `@deepseek-ai/dsh-tool-session-query`, so default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin.
+
+## Alternatives considered
+
+- **Expose provider cursors to the model** — rejected because recording a tool result or starting the next model step changes the relevant session or global generation, so a cursor is usually stale before the model can reuse it.
+- **Add tool-local truncation, offsets, or spill files** — rejected because the post-execute spill policy already owns complete-result retention and retrieval across tools.
+- **Allow every persisted session or model-supplied workspace filters** — rejected because `ctx.sessionQuery` is a trusted service and the model-facing consumer must enforce the caller's authority boundary.
+- **Combine search, tracing, and exact reads into one operation selector** — rejected because narrow names give the model clearer schemas, defaults, presentation intents, and follow-up choices.
+- **Return only one lineage hop** — rejected because spill removes the inline-size motivation while one-hop output would omit relationships with no continuation path.
+
+## Verification
+
+Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration, app composition tests, and assembled ACP request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available where mounted. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior.
+
+## Consequences
+
+Models gain provider-independent access to prior session work without receiving storage authority or continuation state. Search has a finite per-call work bound and may require a narrower query to reach matches beyond the first 100; search calls cannot overlap siblings, while exact observations retain parallel scheduling. Complete traces and event payloads may become spill references instead of inline text. Exact string `cwd` equality favors a conservative security boundary over resolving symlink-equivalent paths. Custom compositions may mount the tool without spill, but then they explicitly accept complete inline trace and read results.
diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md
new file mode 100644
index 0000000000..7fbe746681
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md
@@ -0,0 +1,55 @@
+# Agent Note: 面向模型的会话查询工具
+
+Status: implemented
+
+[English](2026-07-24-model-facing-session-query-tools.md) | 中文
+
+## 问题
+
+统一的 `ctx.sessionQuery` 服务对优先使用实时数据的会话日志提供精确读取、过滤、关系追踪与全文搜索,但模型无法直接使用该服务。若把提供方请求类型交给模型,还会暴露不稳定的分页游标、受信任的语料范围、存储形态的时间值,以及更适合程序化消费者而非模型推理的结果记录。大型追踪与事件负载另有输出大小问题,但若在该消费者内部解决,就会重复 harness 的通用 spill 机制,并使会话查询工具与其他工具的行为不一致。
+
+## 决策
+
+`@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。
+
+该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包契约。
+
+`session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。
+
+面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。
+
+## 工作区权限
+
+每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。
+
+每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。
+
+搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。
+
+## 无游标结果与 spill
+
+两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。
+
+追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。
+
+会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。
+
+## 宿主组合
+
+该消费方是一个需显式启用的插件。发布的 ACP 与 TUI 应用为非模型消费方挂载 `ctx.sessionQuery`,而 Web/headless 共享组合既不挂载查询服务,也不挂载该消费方。发布的组合均未挂载 `@deepseek-ai/dsh-tool-session-query`,因此默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。
+
+## 考虑过的替代方案
+
+- **向模型公开提供方游标**:不予采纳,因为记录工具结果或开始下一个模型步骤会改变相关会话或全局代,导致游标通常在模型能够复用前就已过期。
+- **增加工具本地截断、偏移量或 spill 文件**:不予采纳,因为执行后 spill 策略已经统一负责各工具的完整结果保留与读取。
+- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费者必须执行调用者权限边界。
+- **把搜索、追踪与精确读取合并为一个带操作选择器的工具**:不予采纳,因为职责单一的名称能为模型提供更清晰的 schema、默认值、表现意图与后续选择。
+- **只返回一层谱系**:不予采纳,因为 spill 已消除行内大小方面的理由,而单层输出会遗漏关系且没有继续读取路径。
+
+## 验证
+
+包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置、应用组合测试与组装后的 ACP 请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 在已经挂载该服务的组合中保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。
+
+## 后果
+
+模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。
diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml
new file mode 100644
index 0000000000..590d9227b8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3
+2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f
diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
new file mode 100644
index 0000000000..586995bf45
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
@@ -0,0 +1,60 @@
+# Agent Note: Session List Browsing and Manual Workspace Order
+
+Status: implemented
+
+English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md)
+
+## Problem
+
+[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group.
+
+Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one.
+
+## Decision
+
+### Flat view and viewing state
+
+The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads.
+
+### Row interactions
+
+- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight.
+- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them.
+- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard).
+
+### workspace.rename
+
+`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check.
+
+### Manual order: insertSessionBefore replaces activity pinning
+
+The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal.
+
+The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame.
+
+### Shell/region split
+
+ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
+
+## Alternatives considered
+
+**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner.
+
+**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections.
+
+**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order.
+
+**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only.
+
+**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent.
+
+## Consequences
+
+- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics.
+- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features.
+- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction.
+- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations.
+
+## Testing
+
+Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`.
diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md
new file mode 100644
index 0000000000..432d5167a5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md
@@ -0,0 +1,60 @@
+# Agent Note: Session List Browsing and Manual Workspace Order
+
+Status: implemented
+
+[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文
+
+## Problem
+
+[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。
+
+两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。
+
+## Decision
+
+### 平铺视图与浏览态
+
+group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
+
+### 行交互
+
+- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。
+- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。
+- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。
+
+### workspace.rename
+
+`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。
+
+### 手动排序:insertSessionBefore 取代活动置顶
+
+`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。
+
+UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。
+
+### 壳/区域切分
+
+ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
+
+## Alternatives considered
+
+**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。
+
+**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。
+
+**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。
+
+**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。
+
+**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。
+
+## Consequences
+
+- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。
+- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。
+- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。
+- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。
+
+## Testing
+
+包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。
diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
new file mode 100644
index 0000000000..3295a845f3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b
+2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202
diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
new file mode 100644
index 0000000000..a02087235a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
@@ -0,0 +1,117 @@
+# Agent Note: Workspace UI Complete Product Flow
+
+Status: implemented
+
+English | [中文](2026-07-25-workspace-ui-product-flow.zh.md)
+
+## Problem
+
+[Domain KV Storage and the Workspace Entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) defines the persistent Workspace entity, path conventions, and ordered Session ledger, but not the Host wiring, historical-data initialization, or GUI flow. The GUI presents both Workspaces and Sessions; users must be able to type immediately after entering New Session, even when no Host Session or Host Workspace exists yet.
+
+Pending Workspaces, pending Sessions, retained input, and Host entity publication need clear owners and must preserve the same page identity when RPC completions and Host frames arrive in either order. Eagerly creating a Host Session for the zero state would bring a page with no input into the Host lifecycle. Historical Sessions also expose only the lightweight `SessionHeader.cwd` for grouping; initialization cannot read event bodies.
+
+## Decision
+
+### Host and persistent data
+
+The Host provides the following GUI wiring on the Workspace entity:
+
+| RPC | Behavior |
+| --- | --- |
+| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation |
+| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict |
+| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path |
+| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it |
+| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session |
+
+`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting.
+
+A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly.
+
+The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, the Registry calls only `SessionPersistence.list()` to read header metadata; it calls neither `load` nor `inspect`, reads no history, and parses no event bodies. Valid cwd values are grouped by canonical path, and both Sessions within each group and the Workspace groups themselves are initialized in descending header `createdAt` order. Bootstrap is reentrant and writes the marker last; after the marker is written, new Sessions created without `workspaceId` are no longer adopted automatically.
+
+### Client object model
+
+`Session` and `Workspace` are frontend objects from the page Intent stage onward.
+
+- A frontend Session preallocates a SessionId when created and owns its Intent target and `pendingPrompt`; it remains the same Session object after Host `session.create` succeeds.
+- Before materialization, a frontend Workspace has no WorkspaceId and owns its create input, phase, and error; after Host `workspace.create` succeeds, the same Workspace object adopts the returned view.
+- `SessionManager` and `WorkspaceManager` own object indexes and merge Host baselines and deltas; the objects are the sole source of state for both Intents and Host views.
+- `SessionsService` provides Session objects, real selection, scope, and list projections; `WorkspacesService` depends on `SessionsService` and owns the default Workspace, cross-object New Session flow, and Workspace materialization.
+
+A page has at most one frontend Session Intent and one accompanying Workspace Intent that exists only in the zero-Workspace state. Intents exist only on the current page and disappear on refresh; real Session selection can be restored persistently. Selecting a real Session or starting another Session Intent revokes the old Intent's eligibility for automatic sending, but does not roll back a Session already published by the Host or any accepted message.
+
+The Session owns the first input and drives one internal pipeline: when necessary, it attaches to a Workspace with its preallocated id, then sends `pendingPrompt`. Both attach and send failures return to the same Session. Workspace creation phase and error belong only to the Workspace object; the Session does not simulate the Workspace lifecycle.
+
+### User flow
+
+On initial entry, the application waits until both the Workspace and Session baselines are ready. It restores a real Session selection that remains valid; otherwise, it enters New Session and selects the most recent Workspace exactly once. The most recent Workspace is determined by the maximum `updatedAt` of its member Sessions, falling back to `createdAt` for an empty Workspace. This derived value chooses only the default target: it does not alter the Host Workspace order or trigger another selection after later hydration.
+
+When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order.
+
+Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
+
+Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope.
+
+### First send and recovery
+
+A frontend Session's `pendingPrompt` retains its original text until the Host accepts the message. The first send advances through Workspace materialization, Session attachment, and prompt sending in order:
+
+1. If Workspace creation fails, the Workspace Intent retains its input and error, and the Session continues to target that object.
+2. If Session creation fails before publication, the Session Intent returns to an editable state and retries with the same preallocated SessionId.
+3. `workspace-attach-failed` proves that the Session has been published; the same Session object enters the real list and retains the prompt, and subsequent retries attach it.
+4. If the prompt fails, the Session retains it and retries only send without recreating the Workspace or Session.
+5. If the page switches to another Intent while a Session is being created, the old Session does not send automatically even if it is subsequently published; it retains its original prompt and visible error.
+
+Lost RPC responses, Host frames arriving before completions, and completions arriving before Host frames all converge through the preallocated SessionId and object identity. The Manager performs ordered upserts of Host views and prioritizes preserving the original object identity during local materialization, rather than creating a temporary second row with the same id.
+
+### Sidebar and ordering
+
+Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups.
+
+Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration.
+
+A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows.
+
+Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order.
+
+### React and slot boundaries
+
+React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer.
+
+The Sidebar and conversation empty hero receive standardized actions through slots: `startSession`, `updateSessionPrompt`, `sendSession`, `open`, and `toggleSidebar`. The Workspace picker reuses the same component and the `createWorkspace` seam; its owner supplies only popover state, an anchor, and a selection callback. The presentation layer does not send `host/workspace-changed` directly; Host events originate only from Host mutations and the stream adapter.
+
+## Alternatives considered
+
+**Store separate page records for pending Workspaces and Sessions.** This approach must replace identities after materialization and hand off input, errors, focus, and sidebar rows; Intent state owned by the objects preserves identity continuity.
+
+**Let the presentation layer or root Zustand store orchestrate object lifecycles.** This approach duplicates Manager and Service responsibilities and brings domain state back into React. Runtime services provide standardized actions, while slots inject only the narrow interfaces required by presentation.
+
+**Immediately create a Host Session or Host persistence intent in the zero state.** A page with no input would enter the Host lifecycle and change refresh semantics; before the first send, the frontend Session retains only a page-local Intent.
+
+**Delay an explicit Create Workspace until the first send.** After confirmation, the sidebar would still show no real empty Workspace, conflating “create a Workspace” with “prepare a Session”; only the zero-Workspace Intent generated automatically by the system delays materialization.
+
+**Continuously derive Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit ordering, and would automatically adopt non-Workspace callers; cwd is used only for one historical bootstrap and bidirectional membership validation.
+
+**Have the Client batch-reorder by time after the Session list arrives.** The initial screen would first show the Host order and then jump as a whole, and reconnecting could change positions again; the Host's persistent ledger owns ordering, while the Client merges only individual updates.
+
+**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require double writes; the header retains the Session's own cwd fact, while the Workspace index owns explicit membership.
+
+## Verification
+
+- The zero state with no Workspace writes nothing to the Host and accepts input; explicit Create Workspace immediately creates and displays an empty Workspace.
+- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer.
+- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId.
+- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd.
+- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front.
+- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts.
+- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped.
+- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order.
+
+## Consequences
+
+- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward.
+- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point.
+- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract.
+- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace.
+- Before its first event, a Host Session retains the existing lazy-persistence semantics; frontend Intents do not change empty-Session behavior after a Host restart.
diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md
new file mode 100644
index 0000000000..8ccbf5b984
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md
@@ -0,0 +1,117 @@
+# Agent Note: Workspace UI 完整产品动线
+
+[English](2026-07-25-workspace-ui-product-flow.md) | 中文
+
+Status: implemented
+
+## Problem
+
+[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session;用户进入 New Session 后必须立即输入,即使此时还没有 Host Session,甚至没有 Host Workspace。
+
+待创建 Workspace、待创建 Session、输入保留与 Host 实体发布必须具有明确所有者,并在 RPC completion 与 Host frame 以任意顺序到达时保持同一页面身份。若零态提前创建 Host Session,则无输入的页面状态会进入 Host 生命周期。历史 Session 又只有轻量 `SessionHeader.cwd` 可用于归组,初始化不能读取事件正文。
+
+## Decision
+
+### Host 与持久数据
+
+Host 在 Workspace entity 上提供以下 GUI 接线:
+
+| RPC | 行为 |
+| --- | --- |
+| `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id |
+| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 |
+| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 |
+| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach |
+| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session |
+
+`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。
+
+Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。
+
+Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load`、`inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。
+
+### Client 对象模型
+
+`Session` 与 `Workspace` 从页面 Intent 阶段开始就是前端对象。
+
+- 前端 Session 创建时预分配 SessionId,并在对象内持有 Intent target 与 `pendingPrompt`;Host `session.create` 成功后仍是同一个 Session 对象。
+- 前端 Workspace 在 materialize 前没有 WorkspaceId,并在对象内持有 create input、phase 与 error;Host `workspace.create` 成功后同一个 Workspace 对象 adopt 返回的 view。
+- `SessionManager` 与 `WorkspaceManager` 负责对象索引、Host 基线和增量合并;对象是 Intent 与 Host view 的唯一状态源。
+- `SessionsService` 提供 Session 对象、真实 selection、scope 与列表投影;`WorkspacesService` 依赖 `SessionsService`,负责默认 Workspace、跨对象 New Session 动线和 Workspace materialize。
+
+页面至多有一个前端 Session Intent 和一个仅在零 Workspace 状态下配套的 Workspace Intent。Intent 只存在于当前页面,刷新后消失;真实 Session selection 可以持久恢复。选择真实 Session 或启动另一个 Session Intent 会放弃旧 Intent 的自动发送资格,但已经由 Host 发布的 Session 和已经接受的消息不会回滚。
+
+Session 自己持有首条输入并驱动一条内部流水线:必要时以预分配 id attach 到 Workspace,然后发送 `pendingPrompt`。attach 与 send 的失败都落回同一 Session。Workspace 创建 phase/error 只属于 Workspace 对象,Session 不模拟 Workspace 生命周期。
+
+### 用户动线
+
+应用首次进入时等待 Workspace 与 Session 两份基线 ready。仍有效的真实 Session selection 被恢复;否则进入 New Session,并固定选择一次最近 Workspace。最近 Workspace 取其成员 Session 的最大 `updatedAt`,空 Workspace 回退到 `createdAt`;该派生只决定默认目标,不改变 Host Workspace 顺序,也不会在后续 hydration 时二次改选。
+
+完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。
+
+顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。
+
+Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。
+
+### 首次发送与恢复
+
+前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进:
+
+1. Workspace 创建失败时,Workspace Intent 保留输入与错误,Session 仍指向该对象。
+2. Session 创建在发布前失败时,Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。
+3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt,后续重试 attach。
+4. prompt 失败时,Session 保留 prompt 并只重试 send,不重复创建 Workspace 或 Session。
+5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。
+
+RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。
+
+### Sidebar 与排序
+
+Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。
+
+组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。
+
+前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。
+
+无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。
+
+### React 与 slot 边界
+
+React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。
+
+Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host event 只由 Host mutation 与 stream adapter 产生。
+
+## Alternatives considered
+
+**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。
+
+**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。
+
+**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。
+
+**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,“创建 Workspace”与“准备 Session”语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。
+
+**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序,也会自动收编非 Workspace 调用方;cwd 只用于一次历史 bootstrap 与成员双向校验。
+
+**Client 在 Session list 到达后按时间批量重排。** 首屏会先展示 Host 顺序再整体跳动,重连也可能改变位置;排序由 Host 持久账本拥有,Client 只合并单项更新。
+
+**在 SessionHeader 增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 Session 自身 cwd 事实,Workspace 索引负责显式归属。
+
+## Verification
+
+- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。
+- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。
+- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。
+- Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。
+- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。
+- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。
+- UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。
+- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。
+
+## Consequences
+
+- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。
+- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。
+- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。
+- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。
+- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
index 80f7838d45..d8530ac919 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
@@ -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
-2026-06-17-ts-build-config.md: 1275a635242ea887c941db9dc0554fd33acd4102
-2026-06-17-ts-build-config.zh.md: 7691118dd152874e2849f1ace686069c9ea3f61f
+2026-06-17-ts-build-config.md: 17036438b83a77f72b49f55abf29632af3f4ffef
+2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
index 1275a63524..17036438b8 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
@@ -43,6 +43,8 @@ In-package relative imports use explicit `.ts` specifiers.
- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`.
- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
+Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state.
+
The command orchestration shape is:
```sh
@@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts
pnpm run typecheck:
tsc -b
+
+pnpm run clean:
+tsx scripts/clean.ts
```
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
@@ -63,6 +68,8 @@ tsc -b
- **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape.
- **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works.
+- **Clean before every build** — this would discard the incremental state owned by `tsc` and the bundler even when the workspace layout is unchanged.
+- **Remove every package-level `node_modules`** — valid package dependency links do not cause the workspace-discovery failure, and deleting them would turn build cleanup into dependency reinstallation.
## Consequences
@@ -76,5 +83,6 @@ Build responsibilities are clearer:
- `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`.
- `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish.
- The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries.
+- After changing branches or updating a checkout that deleted packages, contributors can run `pnpm run clean` to remove stale package directories before rebuilding. Unknown files in a manifest-less package directory require manual classification instead of being deleted.
The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired.
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
index 7691118dd1..70e49c61de 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
@@ -43,6 +43,8 @@ Status: implemented
- 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。
- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。
+复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。
+
命令编排结构如下:
```sh
@@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts
pnpm run typecheck:
tsc -b
+
+pnpm run clean:
+tsx scripts/clean.ts
```
`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。
@@ -63,6 +68,8 @@ tsc -b
- **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。
- **用一个根目录严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。
+- **每次构建前都执行清理**:即使工作区布局没有变化,这也会丢弃 `tsc` 和打包器拥有的增量状态。
+- **删除所有包级 `node_modules`**:有效的包依赖链接不会导致工作区发现失败,而删除这些链接会使构建清理变成重新安装依赖。
## 后果
@@ -76,5 +83,6 @@ tsc -b
- `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。
- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。
- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。
+- 切换分支或更新工作副本后,如果其中删除了包,贡献者可在重新构建前运行 `pnpm run clean`,删除残留的包目录。不含 `package.json` 的包目录如果存在未知文件,必须手动判定其类别,不能直接删除。
Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。
diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml
index f59c523dce..0149872c3c 100644
--- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml
@@ -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
-2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5
-2026-06-20-core-data-structures-catalog.zh.md: 8d2f16a46216cba8df0539be0abd2f1ad840eec3
+2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a
+2026-06-20-core-data-structures-catalog.zh.md: 4ace2b8c8a6b08e7721c1df8003ccfbdb128daf1
diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md
index d7e9d3d9b1..ef100f96b0 100644
--- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md
+++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md
@@ -33,7 +33,7 @@ The durability requirement was specific: the doc shows the **literal** current t
- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches.
- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
-- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot.
+- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence** between each primary type-equiv block and one manifest entry, so a block can never be silently unchecked and an entry can never rot. A paired `.zh.md` block reuses the unsuffixed sibling's entry only when the complete tracked fence sequence matches in order, kind, and byte-exact body; otherwise the gate checks it independently, finds no manifest entry, and fails.
- Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks.
### Maintenance is the author's job, with a gate backstop
diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md
index 8d2f16a462..4ace2b8c8a 100644
--- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md
+++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md
@@ -33,7 +33,7 @@ Status: implemented
- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。
- 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。
-- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。
+- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest 条目之间强制执行 **1:1 对应**,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 `.zh.md` 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。
- 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。
### 维护是作者的职责,门禁作为兜底
diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
index 3bfc8414e7..1458f7ff50 100644
--- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
@@ -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
-2026-07-02-bilingual-docs-and-pairing-gate.md: 4bc02878a0ea3f998e411ecc2b064c1626eacf3c
-2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 90a0c2f07f68b0fb4e26cd1c4b537a30a829b10f
+2026-07-02-bilingual-docs-and-pairing-gate.md: 3732e6812a3f1f40242aa5a83a0bf1d1bc4d6139
+2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a870e063230a34b807eed2f4ffc1c6067cb3aedc
diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
index 4bc02878a0..3732e6812a 100644
--- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
+++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
@@ -6,14 +6,14 @@ English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)
## Problem
-This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.
+This repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.
## Decision
- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
-- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.
-- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
+- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.
+- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.
- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.
@@ -40,5 +40,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv
- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, "who confirmed these consistent, and when" is answerable from git blame on the yaml.
- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.
- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.
-- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.
+- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.
- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.
diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
index 90a0c2f07f..a870e06323 100644
--- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
+++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
@@ -6,14 +6,14 @@ Status: implemented
## 问题
-本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。
+本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。
## 决策
- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
-- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。
-- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
+- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。
+- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。
- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。
@@ -40,5 +40,5 @@ Status: implemented
- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。
- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。
- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。
-- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。
+- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。
- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。
diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml
index 45455444d5..0a8609229e 100644
--- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml
@@ -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
-2026-07-04-doc-tiers-and-budgets.md: 7acdba8bfd96d183c418b3935d7b8e7f237d3607
-2026-07-04-doc-tiers-and-budgets.zh.md: 0f07a92740d31dd13cb523d73ac8a3699d666d30
+2026-07-04-doc-tiers-and-budgets.md: a52c40a9a147fd39fdec4c61079822f1b1115227
+2026-07-04-doc-tiers-and-budgets.zh.md: d03c2046c9963e4d62d2a7221d4563f60d3f4953
diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md
index 7acdba8bfd..a52c40a9a1 100644
--- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md
+++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md
@@ -12,7 +12,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m
- **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc.
- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them.
-- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600) — the same rollout mechanism as the [translation-pairing `required` list](2026-07-02-bilingual-docs-and-pairing-gate.md). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act.
+- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act.
- **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract.
## Alternatives considered
diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md
index 0f07a92740..d03c2046c9 100644
--- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md
+++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md
@@ -12,7 +12,7 @@ Status: implemented
- **每项事实只归属一处的层级分类。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事件故事、操作指南、各包契约、生成式目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单。
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。
-- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。推进机制与[翻译配对的 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)相同。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。
+- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。
- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml
deleted file mode 100644
index 9aa8a03c52..0000000000
--- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3
-2026-07-06-parallel-github-ci-gates.zh.md: 7d98f842ef1d60a3a5b727f975cb1d93ea6f253c
diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md
deleted file mode 100644
index 5c276f6a75..0000000000
--- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Agent Note: Parallel GitHub CI gates
-
-Status: implemented
-
-English | [中文](2026-07-06-parallel-github-ci-gates.zh.md)
-
-## Problem
-
-The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
-
-The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes.
-
-The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution.
-
-## Decision
-
-The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented.
-
-[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle.
-
-In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed.
-
-Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition.
-
-Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job.
-
-Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded.
-
-[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails.
-
-Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal.
-
-The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational.
-
-## Alternatives considered
-
-- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop.
-- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository.
-- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes.
-- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency.
-- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency.
-- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior.
-
-## Consequences
-
-The shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle.
-
-The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together.
-
-Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck.
diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md
deleted file mode 100644
index 7d98f842ef..0000000000
--- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Agent Note: 并行 GitHub CI 门禁
-
-Status: implemented
-
-[English](2026-07-06-parallel-github-ci-gates.md) | 中文
-
-## 问题
-
-无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包(package)的发布卫生检查、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。
-
-随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。
-
-产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费方抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。
-
-## 决策
-
-下述生产拓扑已经成为历史,并由[基于证据采用更大的托管 runner](2026-07-22-evidence-based-larger-hosted-runners.md) 取代。更大 runner 的决策移除了其分片选择器和工作流 job;本文保留早期拓扑为何被实现的记录。
-
-[CI](../../../../.github/workflows/ci.yml) 将非 Windows job 的一分钟和 Windows job 的三分钟视为观测所得的性能目标,而非取消截止时间。托管 runner 的波动应留下完整计时证据和有用的失败日志,而不是取消本来正确的门禁。[串行跨平台 CI 参考](2026-07-21-serial-cross-platform-ci-reference.md)会在 Linux、macOS 和 Windows 上独立运行完整、未分片的主 Node 聚合,使优化后的车道清单不会成为自身完整性的唯一判据。
-
-在该拓扑中,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 是通用的有界调度器,GitHub 则为昂贵的门禁族提供显式分片名称。`scripts/static-shards.ts` 将静态门禁划分为基础、文档类型、API 契约、目录、正文、文档投影和文档构建等归属,并拒绝缺失或重复的门禁分配。Linux lint 使用互不重叠的 A-C、D-M、N-S、T-Z 包源码和包测试车道,Windows 则使用完整的包源码与包测试车道;两者都包含从 `.` 开始的仓库补集,使新增顶层目标无法消失在分片之间,并负责唯一一次跨文件重复检查。`scripts/coverage-shards.ts` 把每个 workspace 包恰好分配给一个源码覆盖率车道。目录过滤器保留尾部分隔符,因为 Vitest 位置过滤器按子字符串匹配,否则会纳入具有同名前缀的相邻项。每个覆盖率车道只包含其拥有的源码文件,重复运行穷尽式伴随拓扑测试,并且不先执行构建,因为从删除了所有生成式 `lib/` 的树开始,完整覆盖率套件仍可通过。
-
-快照重放使用两个显式多文件车道,以及大型 ACP(Agent Client Protocol)文件的八个场景分区。`scripts/snapshot-shards.ts` 拥有该清单,其测试会发现快照配置允许的每个文件。每个快照 job 在其 Linux runner 准备 Bubblewrap 的同时安装依赖,随后构建已发布运行时,并且只运行分配给它的重放表面。该套件保留五个子进程的有界并发,因为重放的大部分时间都在等待子进程协议 I/O。fixture(测试前置数据)守卫仍会在每个分区中检查完整 ACP 场景表。
-
-冷启动的独立文档类型检查会重建完整的项目引用图,因此专用文档类型车道只构建一次,再用这些声明检查 Markdown 块。Linux 文档车道使用 VitePress 的 MPA 构建,在观测所得的非 Windows 目标内保留页面渲染与死链接验证;单独的阻塞式 Windows 构建和生产站点车道保留已生成包与已发布站点检查,同时避免把两条关键路径放进同一个 job。
-
-产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费方之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。
-
-[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时分片,仍会失败。
-
-兼容性车道会在每条声明支持的 Node 版本线上运行源码 worker 和 Zstandard 运行时冒烟。TypeScript 在专用的主 Node 24 车道中只检查一次源码图;在运行时兼容性 job 中重复同一编译器分析只会增加耗时,不会提供运行时特有信号。
-
-工作流缓存 pnpm store,将每个不可变 ESLint 缓存的键绑定到其所属 lint 分片,为 Windows 测量保留原生 PowerShell,并保留一个聚合的 `all checks passed` 状态用于分支保护。Windows 复用三个穷尽式 lint 分区,并在共享 runner 设置后组合基础/目录/正文门禁与文档类型/API 契约门禁;只有调度方式与 Linux 分区不同。Windows 构建和生产站点验证继续阻塞,而更广泛的 Windows 静态、lint 和产物矩阵仍为观察性检查。
-
-## 曾考虑的替代方案
-
-- **保留宽车道**:最大限度减少工作流 YAML,但会保留观测到的数分钟反馈周期。
-- **让每个叶子门禁分别成为 GitHub job**:最大化扇出,但短小的生成器和正文检查准备 runner 的时间会超过检查仓库的时间。
-- **向产物消费方上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。
-- **在两个发布门禁中保留包管理器打包**:把清单选择委托给 pnpm,但会重复启动 200 多个包管理器进程。清单结构门禁加发布视图 fixture 使优化后的清单契约显式化,并会在存在磁盘上有但未发布的依赖时失败。
-- **在覆盖率前保留构建**:提供源码套件已不再消费的生成输出;干净树覆盖率证明表明这只是纯粹的延迟。
-- **在每个 Node 版本上执行类型检查**:重复编译器工作,而兼容性冒烟已经验证实际的 Node 特有加载与压缩行为。
-
-## 后果
-
-上述分片清单和矩阵 job 不属于当前仓库契约。取而代之的更大 runner 决策在单个进程中保留完整主清单,并以串行套件作为独立完整性判据。
-
-优化后的发布验证器依赖由 `verify-package-invariants` 强制执行的清单 `files` 契约。如果发布规则超出该契约,结构门禁和两个暂存视图必须一起变化。
-
-兼容性 job 不再声称 TypeScript 本身已在每个 Node 运行时下执行。它们证明 Node 22、24 和 26 上对运行时敏感的源码加载,而主运行时负责唯一一次源码图类型检查。
diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml
new file mode 100644
index 0000000000..9a0f4cfbda
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01
+2026-07-13-documentation-site-projection.zh.md: 9df230ea8adeb8744387a5f7efdf288d6a1f6eaa
diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md
index a5c15686ae..2452c9dfa5 100644
--- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md
+++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md
@@ -2,6 +2,8 @@
Status: implemented
+English | [中文](2026-07-13-documentation-site-projection.zh.md)
+
## Problem
The repository needs a navigable documentation website without turning the website directory into a second documentation source. Copying package guides, architecture pages, or generated catalogs into a site-specific tree allows the two copies to drift, while pointing VitePress directly at the repository root couples public URLs and navigation to the internal file layout. Repository-relative links also need different destinations on the website: published pages stay inside the site, but source files and unpublished contributor documents belong on GitHub.
diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md
new file mode 100644
index 0000000000..9df230ea8a
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md
@@ -0,0 +1,47 @@
+# Agent Note: 将规范文档投影到网站
+
+Status: implemented
+
+[English](2026-07-13-documentation-site-projection.md) | 中文
+
+## 问题
+
+仓库需要一个可导航的文档网站,但不能让网站目录成为第二个文档源。把包(package)指南、架构页面或生成目录复制到网站专用目录树,会使两份副本发生漂移;让 VitePress 直接指向仓库根目录,又会把公开 URL 和导航与内部文件布局耦合。仓库相对链接在网站上也需要指向不同位置:已发布页面应留在站内,源文件和未发布的贡献者文档则应指向 GitHub。
+
+## 决策
+
+规范 Markdown 保留在拥有它的仓库层级中。面向产品的指南位于 `docs/user/`,生成的参考资料保留在现有生成目录中,架构页面和实操手册(cookbook)页面也保留在现有的 `docs/` 路径。
+
+`website/docs.ts` 是一份显式的发布 manifest(元数据清单)。每个条目将一个规范源文件映射到稳定的公开路由、侧边栏、分区和顺序。因此,新增或移除已发布页面是一项可评审的 manifest 变更,而不是隐式目录扫描的结果。
+
+在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由词汇。每个页面都会获得一个指向其规范仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。
+
+各 locale 的首页投影只保留规范 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。
+
+投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影快速失败。单元测试固定这些转换,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。
+
+`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举已跟踪且未被忽略的文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。
+
+Mermaid 渲染规范图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。
+
+网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。
+
+## 考虑过的替代方案
+
+**在 `website/` 下提交复制的 Markdown。** 这种方式让 VitePress 设置更直接,但每份复制的指南或 API 表格都会多出一个所有者,并且需要一套无法识别权威副本的同步约定。
+
+**让 `website/` 成为每个已发布页面的规范归属。** 这种方式仍只有一份副本,却只是为了满足渲染器,就把架构、生成的参考资料和面向贡献者的材料移出了各自的仓库归属层级。
+
+**自动发现所有 Markdown 文件。** 这种方式最大限度减少 manifest 维护,却会意外发布内部文档、把源文件移动暴露为 URL 变更,并根据偶然的目录顺序生成导航。
+
+**使用文件系统符号链接。** 符号链接保留单一来源,却无法解决公开路由或仓库相对链接问题,而且在本地开发、包工具和托管 CI 环境中的行为不够可预测。
+
+**只在部署工作流中构建。** 部署作业可以在合并后发现渲染故障。把生产构建纳入 `doc-sync`,则无论是否存在公开部署,同一个故障都能在本地和常规 CI 中暴露。
+
+**硬编码公开项目路径。** 固定的 `/deepseek-harness/` base 适用于公开项目 URL,却不适用于私有 Pages 站点分配的唯一源站,也不适用于未来的自定义域名。使用 Pages 元数据可让这些目标位置共享同一份构建契约。
+
+## 后果
+
+文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视规范输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复与验证入口。
+
+发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留规范文档中已经使用的图表。
diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml
index ae5ed9b11e..b2297f4fb4 100644
--- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml
@@ -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
-2026-07-19-require-agent-notes-for-non-trivial-changes.md: f2645832ebcdd0b81cbff5415c7eb6f60b6fa8cf
-2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 659aa7cad0823fa0082be1827f8c083037376a4c
+2026-07-19-require-agent-notes-for-non-trivial-changes.md: 162ae61affb4c1b0ad526fa0da41f84ebbb02089
+2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: cd015ba62f1f2b1e9e5e6c36d1cde5bd35cba84c
diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md
index f2645832eb..162ae61aff 100644
--- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md
+++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md
@@ -14,6 +14,10 @@ Every non-trivial change adds or updates at least one Agent Note in the same PR.
Updating the note that already owns a decision satisfies the rule; a new note is required only when no note owns it. Purely mechanical or local edits with no behavioral, contractual, structural, process, or rationale change are exempt. The [Agent Notes README](../../README.md#when-to-write-one) owns this boundary, while root `AGENTS.md` carries the standing order.
+A fully superseded implemented note may be consolidated into the current owning note and deleted only after that owner preserves every unique rationale, alternative, consequence, verification contract, and named coverage gap. The same change repairs inbound links and removes the Chinese counterpart and consistency record. Partial supersession keeps both notes cross-linked and current; consolidation neither rewrites an old decision into its opposite nor leaves git history as the only copy of rationale.
+
+When a later decision removes an earlier feature completely, the removal note becomes the current owner only after the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the feature's original motivation, why that motivation no longer justified the surface, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Implementation inventories and tests that only described the deleted behavior are obsolete rather than current verification contracts. A removal limited to one transport, default, implementation, or presentation remains partial supersession.
+
Review enforces the semantic boundary. No automated gate attempts to classify a diff as trivial or non-trivial, so this policy adds no gate stage or runtime.
## Alternatives considered
@@ -22,10 +26,21 @@ Review enforces the semantic boundary. No automated gate attempts to classify a
**Require a new note for every change.** This duplicates an existing note when it already owns the decision and adds empty ceremony to purely mechanical edits.
+**Keep every fully superseded note indefinitely.** A cross-linked record is necessary while part of its decision remains current, but a wholly obsolete implemented note contradicts the current-state contract and duplicates rationale that can have one owner.
+
+**Add a `superseded/` lifecycle.** Another lifecycle would retain the obsolete record and expand the tree, format gate, and maintenance rules without reducing duplication.
+
+**Rewrite the old note into the replacement decision.** This erases the decision boundary and its rejected alternatives. Consolidation instead preserves those facts in the current owner before deleting the obsolete file.
+
+**Preserve every implementation and test detail from a removed feature.** This recreates the obsolete note inside its replacement. The removal owner keeps the rationale and verification needed to understand or revisit the current absence, while deleted mechanics remain available in git history.
+
**Add a CI diff-classification gate.** A mechanical check cannot reliably determine whether a semantic change is trivial, while another gate adds runtime and invites false positives or superficial compliance.
## Consequences
- Every substantial change preserves its rationale and rejected alternatives beside the implementation.
- Contributors maintain an existing owning note instead of creating duplicate records.
+- Fully superseded records can collapse into one current owner without losing their unique rationale or verification contract.
+- Features that were later removed can have one current owner without carrying obsolete implementation and test inventories forward.
+- Partial supersession remains explicit and cross-linked, while deletion requires link and bilingual-pair cleanup in the same change.
- Mechanical edits remain lightweight, and the gate topology and runtime remain unchanged.
diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md
index 659aa7cad0..cd015ba62f 100644
--- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md
+++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md
@@ -14,6 +14,10 @@ Status: implemented
更新已经持有该决策的 Agent Note 即满足规则;仅当没有 Agent Note 持有该决策时才新增记录。完全机械或局部、且不改变行为、契约、结构、流程或决策依据的编辑可豁免。[Agent Notes README](../../README.md#when-to-write-one) 持有这条边界,根目录 `AGENTS.md` 则携带常驻指令。
+只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件和一致性记录。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。
+
+后续决策完全移除较早的功能时,只有该功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行,移除记录才会成为当前持有记录。移除决策的依据和验证该功能已不存在的测试可以保留。它必须保留该功能的最初动机、为什么该动机已不足以证明继续保留该功能、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。只描述已删除行为的实现清单和测试已经过时,不属于当前验证契约。仅移除一种传输、默认值、实现或展示仍属于部分取代。
+
评审负责执行这条语义边界。自动化门禁不尝试把差异分类为平凡或实质性变更,因此这项政策不会增加门禁阶段或运行时间。
## 备选方案
@@ -22,10 +26,21 @@ Status: implemented
**每项变更都必须新增 Agent Note。** 当现有 Agent Note 已经持有该决策时,这会产生重复记录,也会让纯机械编辑承担空洞的流程负担。
+**永久保留每份被完全取代的 Agent Note。** 只要旧决策仍有部分适用,就需要保留互相链接的记录;但完全失效的 implemented Agent Note 与记录当前状态的契约相矛盾,并重复保存本可由一个记录持有的决策依据。
+
+**新增 `superseded/` 生命周期。** 新增生命周期仍会保留过时记录并扩张目录树、格式门禁和维护规则,却无法减少重复内容。
+
+**将旧 Agent Note 改写为替代它的决策。** 这样会抹去决策边界及其否决的备选方案。合并做法是在删除过时文件前,先由当前持有决策的记录保存这些事实。
+
+**保留已移除功能的每一项实现与测试细节。** 这会在替代记录中重建过时记录。移除决策的持有记录只保留理解或重新审视当前已移除状态所需的决策依据与验证,已删除机制仍可从 git 历史查看。
+
**添加 CI 差异分类门禁。** 机械检查无法可靠判断语义变更是否平凡,额外门禁还会增加运行时间,并引入误报或表面合规。
## 影响
- 每项实质性变更都会在实现旁保留其决策依据和被放弃的备选方案。
- 贡献者维护现有的决策持有记录,而不是创建重复记录。
+- 被完全取代的记录可以归并到一个当前持有记录中,同时不丢失其独有的决策依据或验证契约。
+- 后来被移除的功能可以只有一个当前持有记录,而无需继续保留过时的实现与测试清单。
+- 仅部分被取代的情况仍需明确记录并互相链接;删除记录则必须在同一变更中清理链接和双语配对。
- 机械编辑仍保持轻量,门禁拓扑和运行时间也保持不变。
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
index b21353698c..ffa1e87103 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
@@ -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
-2026-07-20-gui-testing-system.md: e42dafcdf37e48475e7d420eaad9600e6c20891c
-2026-07-20-gui-testing-system.zh.md: e4ef6246e59e0ad6c0a3070a38c546f964e347fa
+2026-07-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b
+2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
index e42dafcdf3..546f65f065 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
@@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up:
|---|---|---|---|
| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
-| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts` |
+| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
@@ -33,15 +33,15 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes
|---|---|---|---|
| Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source |
| Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery |
-| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery |
+| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery |
| Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window |
**Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions.
## Anti-regression discipline
-- **Every bug fix pins an assertion**: a browser-visible bug is pinned into the regression section of its owning verify script (one pin = one report line); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
-- **All-green on fixture is not done, the real host must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run `verify-session-real`.
+- **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
+- **All-green on fixture is not done, the real wire must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (`pnpm run test:web`) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement.
- The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
## Consequences
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
index e4ef6246e5..6601ae0a1c 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
@@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
|---|---|---|---|
| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` |
-| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过 | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts` |
+| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。
@@ -33,15 +33,15 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
|---|---|---|---|
| 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 |
| 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 |
-| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 |
+| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 |
| 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 |
**浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。
## 防回归纪律
-- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属 verify 脚本的回归节(一钉一行 report);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
-- **fixture 全绿不算完,真 host 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,`verify-session-real` 必跑。
+- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(smoke 或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
+- **fixture 全绿不算完,真 wire 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(`pnpm run test:web`)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host smoke 仍是真模型侧的补充。
- 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/契约为裁),不留悬红。
## Consequences
diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml
index 8bd4745529..46073fc52b 100644
--- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml
@@ -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
-2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2
-2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef
+2026-07-21-doc-sync-through-gate-scheduler.md: d66d9dc75ee4e8268d55e344a53c51c0bcf5f4d4
+2026-07-21-doc-sync-through-gate-scheduler.zh.md: 8c4c4595c2bc6e9439ed24cda1e70ae5a5ccd146
diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md
index b7e41ba4ae..d66d9dc75e 100644
--- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md
+++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md
@@ -10,7 +10,7 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md)
## Decision
-`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
+`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [current CI topology](2026-07-22-evidence-based-larger-hosted-runners.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
`docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs.
diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md
index 56699747b1..8c4c4595c2 100644
--- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md
+++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
+`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[当前 CI 拓扑](2026-07-22-evidence-based-larger-hosted-runners.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
`docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
index 9d87cb9ad3..3d8e6fc395 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml
@@ -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
-2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672
-2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134
+2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf
+2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
index aaeab4ed9a..fe11e69295 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
@@ -20,6 +20,10 @@ The former gate-level and coarse primary shard jobs are absent from the workflow
Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
+The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count.
+
+The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails.
+
Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
@@ -54,6 +58,10 @@ Complete serial Linux, macOS, and Windows references run only when `master` move
**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
+**Return to package-manager packing in each publication validator.** Rejected because it repeats a package-manager subprocess for every package. The manifest-derived publication view and staged compiled self-reference preserve the published-file contract with one in-process inventory.
+
+**Build before coverage or typecheck on every Node version.** Rejected because coverage is source-only and compiler analysis is not runtime-specific. Build-backed consumers still wait for emitted output, and compatibility jobs exercise the runtime-sensitive paths on every advertised Node line.
+
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison.
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
index 72b69c8590..4787928453 100644
--- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
+++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md
@@ -20,6 +20,10 @@ Status: implemented
Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
+门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。
+
+产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。
+
Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。
一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
@@ -54,6 +58,10 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
+**在每个发布校验器中恢复使用包管理器打包。** 不予采用,因为这会为每个包重复启动一个包管理器子进程。根据 manifest 构建的发布视图和已暂存的编译后自身引用,只需一份进程内清单即可保留发布文件契约。
+
+**在每个 Node 版本上先构建,再运行覆盖率或类型检查。** 不予采用,因为覆盖率只消费源码,编译器分析也不依赖运行时。依赖构建产物的消费方仍等待生成的输出,兼容性作业则在每个已声明支持的 Node 版本上验证对运行时敏感的路径。
+
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml
new file mode 100644
index 0000000000..8bc3f87435
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89
+2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e
diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md
new file mode 100644
index 0000000000..61b7a829b8
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md
@@ -0,0 +1,71 @@
+# Agent Note: Semantic pull request label taxonomy
+
+Status: implemented
+
+English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md)
+
+## Problem
+
+Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs.
+
+The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all.
+
+## Decision
+
+Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension.
+
+### Kinds
+
+| Kind | Meaning |
+|---|---|
+| `feature` | Adds or intentionally changes behavior. |
+| `bug-fix` | Corrects incorrect behavior. |
+| `doc` | Makes documentation the dominant intent. |
+| `testing` | Changes tests or testing infrastructure without changing product behavior. |
+| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. |
+
+The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change.
+
+Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work.
+
+### Current areas
+
+The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level.
+
+| Group | Areas |
+|---|---|
+| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` |
+| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` |
+| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` |
+| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` |
+| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` |
+
+`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract.
+
+`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts.
+
+Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators.
+
+### Extensibility
+
+The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together.
+
+The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area.
+
+## Alternatives considered
+
+- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered.
+- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy.
+- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need.
+- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work.
+- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface.
+- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts.
+- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts.
+
+## Consequences
+
+- Reviewers and automation receive one stable intent signal plus a complete semantic scope.
+- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts.
+- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies.
+- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change.
+- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning.
diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md
new file mode 100644
index 0000000000..cc0c5e7a89
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md
@@ -0,0 +1,71 @@
+# Agent Note: 语义化 PR 标签分类体系
+
+Status: implemented
+
+[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文
+
+## 问题
+
+PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。
+
+仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。
+
+## 决策
+
+每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。
+
+### 类型
+
+| 类型 | 含义 |
+|---|---|
+| `feature` | 新增行为或有意改变行为。 |
+| `bug-fix` | 修正错误行为。 |
+| `doc` | 以文档变更为主要意图。 |
+| `testing` | 修改测试或测试基础设施,但不改变产品行为。 |
+| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 |
+
+类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。
+
+领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。
+
+### 当前领域
+
+当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。
+
+| 分组 | 领域 |
+|---|---|
+| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` |
+| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` |
+| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` |
+| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` |
+| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` |
+
+`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。
+
+`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。
+
+标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。
+
+### 可扩展性
+
+领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。
+
+类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。
+
+## 曾考虑的替代方案
+
+- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。
+- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。
+- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。
+- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。
+- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。
+- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。
+- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。
+
+## 后果
+
+- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。
+- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。
+- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。
+- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。
+- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。
diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml
deleted file mode 100644
index 12abdeaada..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-04-fold-stdio-ui-helper.md: b9c4c6cfb7643890a7cf4dcdeb9014d7c7158818
-2026-07-04-fold-stdio-ui-helper.zh.md: d71fc878c603757f7da20aab3e7e219e368e7745
diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
deleted file mode 100644
index b9c4c6cfb7..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# Agent Note: Fold the stdio UI helper into the stdio app
-
-Status: implemented
-
-English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md)
-
-The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely.
-
-## Problem
-
-The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface.
-
-The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it.
-
-## Decision
-
-At the time, the helper moved into `@deepseek-ai/dsh-stdio` as the terminal-channel plugin. `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, keeping EOF handling, rendering, disposal, and piped-vs-TTY behavior under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape consumed by the app mount, while the then-current Echo and REPL Loader smokes proved the composed tree and the plugin-shape suite pinned explicit `unwrapExports` behavior. The superseding removal note above owns the current package and example state.
-
-The earlier support helper package was removed: its manifest, tsconfig references, module-graph rows, and README rows disappeared, while the remaining documentation described the in-package module.
-
-## Alternatives considered
-
-### Why not promote it to `ui/` instead?
-
-Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is an automation protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract.
-
-## Consequences
-
-- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos.
-- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse.
diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md
deleted file mode 100644
index d71fc878c6..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# Agent Note: 将 stdio UI 辅助模块折入 stdio 应用
-
-Status: implemented
-
-[English](2026-07-04-fold-stdio-ui-helper.md) | 中文
-
-后来的[冗余 agent(智能体)移除](2026-07-20-remove-stdio-and-echo-agents.md)取代了这项包放置决策,并完整移除合并后的包、应用和面向行的表面。
-
-## 问题
-
-readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。
-
-这条边界换来的是:包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群始终包含 readline UI,且没有其他消费方能有意义地使用它。
-
-## 决策
-
-当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试 seam 和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。
-
-早期的支持辅助包已移除:其清单、tsconfig 引用、模块图行和 README 行均已消失,其余文档改为描述包内模块。
-
-## 曾考虑的替代方案
-
-### 为什么不将其提升到 `ui/` 而是折入?
-
-提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP(Agent Client Protocol)桥接保留为独立包,因为它是具有自身契约和快照层级的自动化协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。
-
-## 后果
-
-- stdio 应用完整拥有自己的前门;叶子 `cordis.yml` 仍然只加载一个应用包,演示的形态没有变化。
-- 未来如果有独立的终端 UI 需要将该辅助模块作为包使用,届时由那个第二消费方驱动重新引入,而非仓库为假设性的复用保留一条边界。
diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml
index 91e9b078ad..89b7165650 100644
--- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml
@@ -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
-2026-07-20-remove-stdio-and-echo-agents.md: 2aba8193710c96d3726b91062bfa43d039b4cabf
-2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c3916683f4743384a2ce4104319da26145837fe
+2026-07-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a
+2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95
diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
index 2aba819371..d804f8e3c3 100644
--- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
+++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
@@ -18,8 +18,8 @@ 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. `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots.
-- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `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-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-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.
The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents.
@@ -28,11 +28,14 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt
## Verification
-TUI and Headless Loader coverage run the real app packages in source and built modes. TUI uses a pseudo-terminal; Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, and SDK-interface references.
+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.
## Alternatives considered
- **Keep the line agent only for pipes** — rejected because Headless has a bounded task contract, format-pure stdout, durable completion, and process exit status.
+- **Keep, fold, or promote the readline helper as a package** — rejected because it had one app consumer and no independently swappable contract. Folding it into the stdio app removed an unjustified support-package boundary but still retained the redundant product; a future standalone line UI needs a real second consumer before reintroducing that package.
- **Keep Echo as the keyless quick start** — rejected because the first product experience should exercise the real model and supported coding agent, not a scripted adapter with a bespoke tool.
- **Keep Echo only as a CI demo command** — rejected because test-owned Headless fixtures cover the same Loader and built-artifact boundaries without preserving a mock product leaf.
- **Remove every stdio or mock mechanism** — rejected because framed protocols, process I/O, and deterministic test adapters are independent infrastructure, not the removed agents.
@@ -43,3 +46,4 @@ TUI and Headless Loader coverage run the real app packages in source and built m
- The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`.
- CI retains keyless real-entry coverage through test fixtures rather than a product command.
- Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated.
+- Piped multi-turn interaction in one process and the readline provider for non-TTY `ask_user_question` are intentionally gone; resume covers durable multi-turn work, and a non-TTY composition must supply its own interaction provider.
diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md
index 2c3916683f..d42e6d5272 100644
--- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md
@@ -18,8 +18,8 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个
保留的应用角色均有明确归属:
-- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。`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-tui-demo`](../../../../packages/examples/tui-demo/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` 负责各自的分帧协议集成。
SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。
@@ -28,11 +28,14 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换
## 验证
-TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。TUI 使用伪终端;Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点和 SDK 接口引用。
+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 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。
## 曾考虑的替代方案
- **仅为 pipe 保留面向行 agent**:不予采纳,因为 Headless 已提供有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。
+- **将 readline helper 作为包保留、折叠或提升**:不予采纳,因为它只有一个 app 消费方,并不存在可独立替换的契约。将它折叠进 stdio app 虽然移除了没有正当理由的支撑包边界,却仍保留了重复产品;将来要重新引入这个包,独立的面向行 UI 必须先有真正的第二个消费方。
- **保留 Echo 作为无密钥快速上手路径**:不予采纳,因为首次产品体验应使用真实模型和受支持的 coding agent,而不是带专用工具的脚本化适配器。
- **只为 CI 演示命令保留 Echo**:不予采纳,因为由测试持有的 Headless fixture 可以覆盖相同的 Loader 和构建产物边界,无需保留 mock 产品叶节点。
- **移除所有 stdio 或 mock 机制**:不予采纳,因为分帧协议、进程 I/O 和确定性测试适配器是独立基础设施,并不是被移除的 agent。
@@ -43,3 +46,4 @@ TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真
- 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。
- CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。
- 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。
+- 有意移除了单进程内基于管道的多轮交互,以及面向非 TTY `ask_user_question` 的 readline 提供方;恢复会话可以满足持久多轮工作,非 TTY 组装则必须自行提供交互提供方。
diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml
deleted file mode 100644
index 7b529ad317..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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-retire-readline-front-door.md: 166e9ca17989ff14f9c3f38cd9650387581b0f78
-2026-07-20-retire-readline-front-door.zh.md: 8c2568f60c3a12fb16a9ef4fe1775e875966a49a
diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
deleted file mode 100644
index 166e9ca179..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Agent Note: Retire the readline front door and the repl-agent example
-
-Status: implemented
-
-English | [中文](2026-07-20-retire-readline-front-door.zh.md)
-
-## Problem
-
-The repo shipped two interactive terminal front doors: the line-oriented readline channel (`@deepseek-ai/dsh-stdio`) and the full-screen [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md). After the TUI landed, readline's interactive role was redundant — `demo:tui` superseded `demo:repl` as the coding-agent experience — while its remaining real role, pipes and automation, was already served better by the one-shot `@deepseek-ai/dsh-cli-demo` app (task in, DSH-native `text`/`json`/`stream-json` out, durable persistence, signal handling).
-
-The duplication was structural, not just cosmetic: `dsh-stdio-demo` carried a `TerminalMode` (`auto`/`readline`/`tui`) selection seam, ~1,000 lines of readline unit tests, a readline transcript grammar (`[tool call] …` lines) that the CI demo smoke and two built-bin e2es grepped, and an inverted example composition where the flagship `tui-agent` leaf was defined as an include-patch over the `repl-agent` leaf it superseded.
-
-## Decision
-
-Delete the readline front door and the repl-agent example; keep exactly three front-door archetypes: **interactive TUI** (TTY-only, fails loud on pipes), **one-shot CLI** (`-p`/positional task, pipes and automation), and **servers** (ACP / JSON-RPC).
-
-- `packages/ui/stdio` and `examples/repl-agent` are gone. `packages/examples/stdio-demo` is renamed `@deepseek-ai/dsh-tui-demo` (`packages/examples/tui-demo`) and always mounts `dsh-tui`; the `TerminalMode`/`resolveTerminalMode`/`ui.mode` seam is deleted. The bin refuses non-TTY streams **before booting the Loader** (a compose-time throw inside a Loader tree is logged per-entry, not rethrown, so a piped launch would otherwise settle into an idle UI-less process instead of exiting nonzero).
-- `examples/tui-agent/cordis.yml` now owns the coding composition inline (the include-patch inversion is gone); its Code Mode overlay includes its own base. `examples/cordis-agent` moved to the TUI app.
-- `examples/echo-agent` moved to the one-shot `dsh-cli-demo` app; `dsh-cli-demo` gained `-p/--prompt` as the flag form of the single task (mutually exclusive with the positional).
-- The UI-independent with-key coding e2es (`full-loop`, `coding-task`, `resume`, `compaction`, `todo-write`, `code-mode` and their shared harness) moved verbatim from `examples/repl-agent/tests/` to `examples/tui-agent/tests/` — they assemble the stack programmatically and never touched a UI.
-- The SDK wizard's `stdio` run interface became `tui` (`RunInterface = 'acp' | 'tui' | 'embed'`), contributing a `dsh-tui` entry instead of `dsh-stdio`; the generated `index.ts` guards TTY before `startSDK` for the same pre-boot fail-loud reason as the tui-demo bin.
-
-### Testing policy: PTY only for the TUI
-
-Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned **only** where the subject is the TUI itself: `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` (which gained the Code Mode overlay boot scenario, replacing repl-agent's pipe smoke as the overlay's keyless composition proof) and the minimal PTY boot smoke in `examples/cordis-agent` (whose front door IS the TUI). Everything else moved to pipes over the one-shot bin:
-
-- `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines.
-- The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally.
-- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite.
-- `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec.
-
-## Accepted losses
-
-- **Piped multi-turn in one process** — the readline channel could script several turns over stdin; the one-shot bin runs one task per process. Multi-turn continuity is covered by `RESUME_SESSION_ID`/resume e2es and the TUI's scripted PTY conversation.
-- **Non-TTY `ask_user_question`** — the readline provider was the only non-TTY terminal implementation of `ctx.userInteraction`. A headless or ACP automation run whose model calls `ask_user_question` fails that tool call unless its composition supplies a provider; Web owns the shipped non-terminal provider.
-
-## Alternatives considered
-
-- **Keep `dsh-stdio` as a pipe/automation channel without the repl demo** — rejected: its automation role duplicated `dsh-cli-demo` with a weaker contract (unstructured transcript, EOF-exit heuristics vs. one durable turn ending and format-pure output).
-- **Rewrite the piped smokes as PTY drivers** — rejected: PTY is the flakier, more complex medium and is reserved for the one surface pipes cannot prove (real TTY takeover/restore).
-
-## Consequences
-
-- One interactive front door (TUI), one automation front door (one-shot CLI), two servers; no mode-selection seam in the terminal app.
-- ~1,000 lines of readline unit tests deleted with their behavior; the readline transcript grammar is gone from all gates.
-- This supersedes the packaging half of [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) (the folded package is now deleted) and amends the composition described in [the TUI front-door note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) (no `auto` selection; `tui-agent` owns the coding composition).
diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md
deleted file mode 100644
index 8c2568f60c..0000000000
--- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Agent Note: 退役 readline 前端与 repl-agent 示例
-
-Status: implemented
-
-[English](2026-07-20-retire-readline-front-door.md) | 中文
-
-## 问题
-
-仓库同时提供两个交互式终端前端:面向行的 readline 通道(`@deepseek-ai/dsh-stdio`)和全屏的 [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md)。TUI 落地之后,readline 的交互角色已经冗余——`demo:tui` 作为编码 agent 体验取代了 `demo:repl`——而它剩下的真实角色(管道与自动化)已由单次任务的 `@deepseek-ai/dsh-cli-demo` 应用以更好的方式承担(任务输入、DSH 原生 `text`/`json`/`stream-json` 输出、持久化、信号处理)。
-
-这种重复是结构性的,不只是表面问题:`dsh-stdio-demo` 携带一个 `TerminalMode`(`auto`/`readline`/`tui`)选择接缝、约 1,000 行 readline 单元测试、一套被 CI 演示冒烟测试和两个 built-bin e2e 用 grep 匹配的 readline 文本记录语法(`[tool call] …` 行),以及一个倒置的示例组合:旗舰 `tui-agent` 叶节点被定义为对它所取代的 `repl-agent` 叶节点的 include patch。
-
-## 决定
-
-删除 readline 前端和 repl-agent 示例;只保留三类前端原型:**交互式 TUI**(仅 TTY,管道下快速失败)、**单次任务 CLI**(`-p`/位置参数任务,服务管道与自动化)以及**服务器**(ACP / JSON-RPC)。
-
-- `packages/ui/stdio` 与 `examples/repl-agent` 已删除。`packages/examples/stdio-demo` 更名为 `@deepseek-ai/dsh-tui-demo`(`packages/examples/tui-demo`)并始终挂载 `dsh-tui`;`TerminalMode`/`resolveTerminalMode`/`ui.mode` 接缝随之删除。bin 在**启动 loader 之前**就拒绝非 TTY 流(Loader 树内组合期抛出的异常按条目记录日志而不会重新抛出,管道启动否则会沉降为一个空闲的无 UI 进程而不是以非零码退出)。
-- `examples/tui-agent/cordis.yml` 现在内联拥有编码组合(include patch 倒置消失);其 Code Mode 覆盖层 include 自己的基础配置。`examples/cordis-agent` 迁移到 TUI 应用。
-- `examples/echo-agent` 迁移到单次任务的 `dsh-cli-demo` 应用;`dsh-cli-demo` 新增 `-p/--prompt` 作为单个任务的旗标形式(与位置参数互斥)。
-- 与 UI 无关的带密钥编码 e2e(`full-loop`、`coding-task`、`resume`、`compaction`、`todo-write`、`code-mode` 及其共享 harness)原样从 `examples/repl-agent/tests/` 移入 `examples/tui-agent/tests/`——它们以编程方式组装整个栈,从不接触任何 UI。
-- SDK 向导的 `stdio` 运行接口改为 `tui`(`RunInterface = 'acp' | 'tui' | 'embed'`),贡献 `dsh-tui` 配置项而不是 `dsh-stdio`;生成的 `index.ts` 在 `startSDK` 之前检查 TTY,理由与 tui-demo bin 的启动前快速失败相同。
-
-### 测试策略:PTY 仅用于 TUI
-
-管道仍是默认测试介质。PTY 驱动的子进程测试**仅**在被测对象就是 TUI 本身时获准使用:`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`(新增 Code Mode 覆盖层启动场景,取代 repl-agent 的管道冒烟测试成为该覆盖层的无密钥组合证明)和 `examples/cordis-agent` 中最小的 PTY 启动冒烟测试(其前端就是 TUI)。其余全部改为通过单次任务 bin 走管道:
-
-- `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。
-- CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。
-- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。
-- `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。
-
-## 接受的损失
-
-- **单进程内的管道多轮对话**——readline 通道可以通过 stdin 脚本化多个轮次;单次任务 bin 每个进程只运行一个任务。多轮连续性由 `RESUME_SESSION_ID`/resume e2e 和 TUI 的脚本化 PTY 对话覆盖。
-- **非 TTY 的 `ask_user_question`**——readline 提供方是 `ctx.userInteraction` 唯一的非 TTY 终端实现。模型调用 `ask_user_question` 的 headless 或 ACP 自动化运行会让该工具调用失败,除非其组合提供相应的 provider;Web 拥有已交付的非终端 provider。
-
-## 曾考虑的替代方案
-
-- **保留 `dsh-stdio` 作为纯管道/自动化通道而只删 repl 演示**——不予采纳:它的自动化角色以更弱的契约重复了 `dsh-cli-demo`(非结构化文本记录、EOF 退出的启发式判断,对比后者的一次持久轮次结束和格式纯净输出)。
-- **把管道冒烟测试改写为 PTY 驱动**——不予采纳:PTY 是更易波动、更复杂的介质,仅保留给管道无法证明的那一个表面(真实 TTY 的接管/恢复)。
-
-## 后果
-
-- 一个交互式前端(TUI)、一个自动化前端(单次任务 CLI)、两个服务器;终端应用不再有模式选择接缝。
-- 约 1,000 行 readline 单元测试随其行为一起删除;readline 文本记录语法从所有门禁中消失。
-- 本决定取代 [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) 的打包部分(被折叠的包现已删除),并修订 [TUI 前端 Agent Note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) 描述的组合(不再有 `auto` 选择;`tui-agent` 拥有编码组合)。
diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml
index d0c69e6c43..dbd47ad90e 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml
@@ -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
-2026-07-22-plan-specific-collaboration-state.md: d6b606d2235b5dbcb7e1882dd34e8965799c1199
-2026-07-22-plan-specific-collaboration-state.zh.md: c0dc22f6ec296a293681b78cebe7cc05f49f774b
+2026-07-22-plan-specific-collaboration-state.md: fb26d15238f0eb1b63fdccc7e48a6c49a44236cf
+2026-07-22-plan-specific-collaboration-state.zh.md: 93186eebb263458bc99e7f7562d065fbf9e5d4bf
diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
index d6b606d223..fb26d15238 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
+++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
@@ -1,4 +1,4 @@
-# Agent Note: Collapse named session modes into plan mode
+# Agent Note: Plan-specific collaboration state
Status: implemented
@@ -10,6 +10,8 @@ The first plan-mode implementation introduced a generic named-mode registry even
The word “mode” also spans unrelated domains. Sandbox mode is an enforcing policy owned by `ctx.sandboxPolicy` and logged as `sandbox/mode`; plan mode is a collaboration stance that contributes guidance and a reviewed exit. Treating both as instances of one named-mode abstraction would obscure their independent ownership. A transport's generic vocabulary is not evidence that the harness needs a generic mode domain.
+Plan mode also needs a durable stance, a reviewable plan artifact, an explicit human boundary, and request reconstruction across resume and fork. Those requirements belong to the plan feature even after the generic registry and interactive ACP projections are removed.
+
## Decision
Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning.
@@ -20,6 +22,18 @@ Human-facing compositions own plan selection and review. This note originally ke
Sandbox mode and approval policy remain separate enforcement axes. Plan mode neither reads nor writes them, and the simplification introduces no shared base type, registry, or preset abstraction across those concepts.
+### Boundary and model contract
+
+`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly on prompt submission, ordinary continuation, or a request-recovery retry; a failed durable append leaves the intent pending for a later boundary.
+
+The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice.
+
+### Reviewed exit
+
+`exit_plan_mode` requires a calling agent in active plan mode and a non-empty markdown plan beginning with a heading. The user-interaction question carries that exact plan as detail and offers `Approve` or `Keep planning` plus free-text feedback. Only one `Approve` selection with no custom text consents; every other answer stays in plan mode and returns corrective feedback to the model. An approved exit becomes a silent pending selection, leaving plan guidance active for the rest of the current tool batch and removing it before the next request.
+
+The tool renders the submitted plan as a generic card titled by its first heading. An absent or failed user-interaction provider, a failed review, or plugin disposal while review is pending fails closed and leaves manual `/plan off` as the human escape path.
+
## Deleted surface
- The arbitrary definition map, mode-name regular expression, reserved-name rules, and per-definition command loop.
@@ -31,16 +45,27 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei
**Keep a private generic registry and expose only plan today.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases.
-**Fold sandbox mode into the same service.** Rejected because collaboration guidance and execution confinement have different owners, lifecycle semantics, and consumers. Their shared English noun is not a domain relationship.
+**Fold sandbox or approval policy into plan state.** Rejected because collaboration guidance, execution confinement, and permission decisions have different owners, lifecycle semantics, and consumers. A mode-owned sandbox cap also makes a user's explicit sandbox selection appear to succeed while silently doing nothing.
**Let one presentation transport own plan state.** Rejected because TUI, Web, resume, fork, prompt assembly, and the exit tool need the same logged fact independently of any one transport. Presentation adapters own only their projections.
+**Split a capability-seam trio or put the state in the agent loop.** Rejected because plan mode has no swappable backend, while existing session, prompt, tool, command, and lifecycle seams already provide every required hook.
+
+**Put flips in surface messages or store plans in files.** Rejected because the stance is a log-only fact and the tool argument already records the reviewable plan. Surface duplication spends model context, while a plan directory creates a second durable home.
+
+**Filter tools by a per-plan name allowlist or a global policy stack.** Rejected because mutability is a property of each tool, including future and MCP tools, rather than a list that every plan deployment must maintain. Effects metadata can establish a shared policy only when a concrete consumer exists; until then plan mode is guidance, not a security boundary.
+
+**Review through the approval seam or prose.** Rejected because a plan review is not a permission decision, needs the exact artifact and corrective free text, and must have a logged tool call as its structured transition. The user-interaction seam supplies that contract.
+
## Verification
- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service.
- Command tests cover bare `/plan`, `/plan `, active `/plan off`, pending-entry cancellation, inactive idempotence, absence of `/mode` and `/review`, and effect-scoped removal.
- The keyless TUI scenarios enter through `/plan `, leave through `/plan off`, and prove that each committed `plan/mode` precedes the request header it changes, the entry message is logged under plan guidance, and the post-exit request omits that guidance.
+- The complete `exit_plan_mode` review arc is package-tested but has no assembled-application snapshot after the interactive ACP scenarios were retired; current keyless TUI scenarios cover command entry and direct exit only.
## Consequences
The implementation has one vocabulary for one shipped feature. Adding another collaboration stance is an explicit design decision instead of a config entry, and automation clients do not acquire human mode controls through ACP. The migration intentionally rejects old `mode/set` logs and old `modes.plan.section` configuration under the repository's pre-release format policy.
+
+Plan state remains reconstructable and tool schemas remain stable, but an idle pending selection is lost if the process exits before the next boundary. Entering or leaving plan mode changes the prompt from order 50 onward, and a model that ignores the guidance can still mutate unless the deployment independently configures sandbox, approval, or filesystem policy.
diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md
index c0dc22f6ec..93186eebb2 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md
@@ -1,4 +1,4 @@
-# Agent Note: 将具名会话模式收敛为 plan mode
+# Agent Note: plan 专用协作状态
Status: implemented
@@ -10,6 +10,8 @@ Status: implemented
「mode」一词还横跨互不相关的领域。沙箱模式是由 `ctx.sandboxPolicy` 拥有、以 `sandbox/mode` 记录日志的强制执行策略;plan mode 则是一种协作方式,会贡献引导内容和经评审的退出路径。若把两者都视为同一个具名模式抽象的实例,就会掩盖二者各自独立的归属关系。传输协议的通用词汇并不能证明 harness 需要通用模式领域。
+Plan mode 还需要持久协作状态、可评审的计划产物、显式人工决策边界,以及跨恢复与 fork 的请求重建。即使移除通用注册表和 ACP 交互投影,这些要求仍归 plan 功能所有。
+
## 决策
Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。
@@ -20,6 +22,18 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/`
沙箱模式与审批策略仍是彼此独立的强制约束轴。Plan mode 既不读取也不写入二者;此次简化也没有为这些概念引入共享基类型、注册表或预设抽象。
+### 边界与模型契约
+
+`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在提示词提交、普通 continuation 或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。
+
+激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。
+
+### 经评审的退出
+
+`exit_plan_mode` 要求调用方 agent 处于激活的 plan mode,并提交一份非空、以标题开头的 markdown 计划。用户交互问题将这份原样计划作为详情,并提供 `Approve`、`Keep planning` 和自由文本反馈。仅当唯一选择为 `Approve` 且没有自定义文本时才视为同意;其他所有回答都会留在 plan mode,并向模型返回纠正性反馈。经批准的退出会成为一项静默的待生效选择,使 plan 引导在当前工具批次的剩余部分继续有效,并在下一次请求前移除。
+
+工具将提交的计划渲染为 generic 卡片,标题取自第一个标题。用户交互提供方缺失或失败、评审失败,或评审待定期间插件被 dispose,都会失败关闭,并保留手动 `/plan off` 作为人类退出路径。
+
## 删除的接口
- 任意定义映射、模式名正则表达式、保留名称规则以及逐定义命令循环。
@@ -31,16 +45,27 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/`
**保留私有的通用注册表,目前只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。
-**将沙箱模式折叠进同一服务。** 不予采纳,因为协作引导与执行约束有不同的归属方、生命周期语义和消费方。二者的英文名称都含「mode」,不代表存在领域关系。
+**将沙箱或审批策略折叠进 plan 状态。** 不予采纳,因为协作引导、执行约束和权限决策有不同的归属方、生命周期语义和消费方。由 mode 拥有的沙箱上限还会让用户显式选择沙箱看似成功,实际却被静默忽略。
**让一种呈现传输拥有 plan 状态。** 不予采纳,因为 TUI、Web、恢复、fork、提示词组装和退出工具都需要独立于任何单一传输使用同一项已记录事实。呈现适配器只拥有各自的投影。
+**拆成能力 seam 三包,或把状态放进 agent loop。** 不予采纳,因为 plan mode 没有可替换后端,而现有的会话、提示词、工具、命令和生命周期 seam 已经提供所需的全部钩子。
+
+**将状态切换写入表层消息,或把计划存入文件。** 不予采纳,因为协作状态是仅日志事实,工具参数已经记录了可评审的计划。重复写入表层会消耗模型上下文,而计划目录会形成第二个持久归属。
+
+**按 plan 专用名称允许列表或全局策略栈筛选工具。** 不予采纳,因为可变性是每个工具自身的属性,包括未来工具和 MCP 工具,而不应由每个 plan 部署维护一份列表。只有出现具体消费方后,effects 元数据才能建立共享策略;在此之前,plan mode 是引导机制,不是安全边界。
+
+**通过审批 seam 或普通文本完成评审。** 不予采纳,因为计划评审不是权限决策,需要精确的计划产物和纠正性自由文本,而且必须以已记录的工具调用作为结构化转换。用户交互 seam 提供了这项契约。
+
## 验证
- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。
- 命令测试覆盖不带参数的 `/plan`、`/plan `、激活状态下的 `/plan off`、取消待生效的进入选择、未激活状态下的幂等性、不存在 `/mode` 和 `/review`,以及随 effect 作用域移除。
- 无密钥 TUI 场景通过 `/plan ` 进入、通过 `/plan off` 退出,并证明每个已提交的 `plan/mode` 都先于其所改变的请求头,进入消息在 plan 引导下记录到日志,且退出后的请求不含该引导。
+- 完整的 `exit_plan_mode` 评审流程有包测试,但交互式 ACP 场景退役后没有组装应用快照;当前无密钥 TUI 场景只覆盖命令进入和直接退出。
## 后果
该实现只用一套词汇描述一项已交付功能。若要添加另一种协作方式,必须显式作出设计决策,而不能只增加配置项;自动化客户端不会通过 ACP 获得面向人类的模式控制。根据仓库的预发布格式策略,本次迁移有意拒绝旧的 `mode/set` 日志与 `modes.plan.section` 配置。
+
+Plan 状态仍可重建,工具 schema 仍保持稳定,但如果进程在下一边界前退出,空闲状态下待生效的选择会丢失。进入或离开 plan mode 会改变提示词顺序 50 处及其后的内容;如果模型忽略引导,仍可能执行修改,除非部署另行配置沙箱、审批或文件系统策略。
diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml
index 3e4286afec..fbe06d209f 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml
@@ -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
-2026-07-22-tui-titles-from-session-title-service.md: 735c940dbb8a84104ab4320d5c535b41690953d5
-2026-07-22-tui-titles-from-session-title-service.zh.md: 8e7e3ef070cc6518476fe0f53355cb0705e74c6a
+2026-07-22-tui-titles-from-session-title-service.md: 04355b9c426af423dec347997f3b8ac62483eb7f
+2026-07-22-tui-titles-from-session-title-service.zh.md: 5fc783c5ca08baba12a60f2aa6b5e286307aa9f0
diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md
index 735c940dbb..04355b9c42 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md
+++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md
@@ -6,11 +6,11 @@ English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md)
## Problem
-Two model-title implementations coexisted after the tui-staging line merged onto master. The TUI carried its own `autoTitle` feature: a fire-and-forget `ctx.llm.stream` call after the first user message that set the terminal window title via OSC 0, with a one-shot latch, its own prompt, its own 40-character cap, and its own resume re-derivation ([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md), [default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md)). Master had meanwhile landed [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md): a `sessionTitle` capability whose accepted revisions are durable `session/title` events, with a deterministic fallback and optional model providers. The TUI already consumed `session/title` for its header subtitle and window title, so a session could be titled twice by different strategies, and the TUI's process-local title was invisible to resume listings, forks, and Web consumers.
+A per-session title makes terminal panes and tabs distinguishable, but a TUI-local model call would create a second title pipeline beside [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md). The local path needs its own prompt, cap, one-shot latch, resume derivation, cancellation, and failure fallback, while its process-local result remains invisible to session listings, forks, Web consumers, and replay. If both paths run, one session can also be titled twice by different strategies.
## Decision
-The TUI-local generation is removed; the session-title service is the one title source. `TuiConfig.autoTitle`, the latch, the abort controller, the title prompt, and `titleLine` are gone from `dsh-tui`. The terminal rename stays: the TUI folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and sets the terminal window title to ` — ` on every accepted `session/title` event — including resumed sessions, whose titles now replay from the log instead of being re-generated.
+The session-title service is the one title source. The TUI contains no `autoTitle` config, title-model request, latch, abort controller, prompt, or output cap. It folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and calls `runtime.terminal.setTitle` with ` — ` on every accepted `session/title` event. The same terminal-safe OSC 0 path handles the configured fallback title, resumed sessions, and live revisions without renaming tmux windows or adding another terminal-control surface.
Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and the scripted PTY fixture) mount `@deepseek-ai/dsh-session-title-first-message-llm`, which inherits the main request's route and replaces the spine's deterministic fallback with a short model summary. Deployments without the provider keep the fallback title from `dsh-agent-spine-demo`'s bundled `SessionTitleService`.
@@ -20,6 +20,16 @@ Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and
**Port auto-title's prompt and cap into the service as a third provider.** The first-message-llm provider already exists with the same cadence, a reviewed prompt contract, durable request records, and supersession fencing; a second near-identical provider would be pure duplication.
+**Use only a truncated first prompt or only a model title.** A deterministic fallback provides an immediate, free title, while an optional model provider improves quality without delaying the main turn. Forcing either strategy removes that deployment choice.
+
+**Make model titles a TUI default or block the first turn for them.** The cost and route belong to composition, and auxiliary title latency must stay off the interaction critical path. The TUI consumes accepted state instead of owning generation policy.
+
+**Rename a tmux window or use a separate terminal escape.** Rejected because the existing terminal adapter's OSC 0 path labels the pane or tab without acquiring tmux ownership or adding a second control API.
+
+## Verification
+
+TUI tests pin restored and live `session/title` consumption, terminal-safe title rendering, the configured fallback, and the absence of a TUI-owned model path. The keyless PTY smoke boots the real composition, accepts a logged provider title, and observes the resulting terminal title. The [log-backed title decision](../feature/2026-07-21-log-backed-session-titles.md) owns provider, persistence, resume, fork, cancellation, and stale-completion coverage.
+
## Consequences
-One title pipeline: durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI sheds ~90 lines and its `llm`-streaming path. The cost is that a title now requires the provider plugin in the composition for model quality — a leaf choice, not a TUI default — and the terminal title changes shape from the bare model summary to the suffixed ` — ` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here.
+One title pipeline is durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI has no `llm`-streaming title path. Model quality requires a provider plugin in the composition, while deployments without one keep the deterministic fallback; the terminal title consistently uses the suffixed ` — ` shape.
diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md
index 8e7e3ef070..5fc783c5ca 100644
--- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## 问题
-tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自带 `autoTitle` 特性:在首条用户消息后发起一次 fire-and-forget 的 `ctx.llm.stream` 调用,通过 OSC 0 设置终端窗口标题,带有一次性闩锁、自己的提示词、自己的 40 字符截断和自己的恢复重推导([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md)、[default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md))。而 master 已落地[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md):一个 `sessionTitle` 能力,其被接受的修订是持久的 `session/title` 事件,带确定性回退和可选的模型 provider。TUI 已经消费 `session/title` 作为横幅副标题和窗口标题,于是一个会话可能被两种策略各标题一次,且 TUI 的进程本地标题对恢复列表、fork 和 Web 消费方不可见。
+每会话标题让终端窗格和标签页易于区分,但 TUI 本地模型调用会在[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md)旁形成第二条标题管线。本地路径需要自己的提示词、截断上限、一次性闩锁、恢复推导、取消和失败回退,而其进程本地结果仍对会话列表、fork、Web 消费方和回放不可见。若两条路径同时运行,同一会话还可能被不同策略命名两次。
## 决策
-移除 TUI 本地生成;session-title 服务是唯一的标题来源。`TuiConfig.autoTitle`、闩锁、abort controller、标题提示词和 `titleLine` 全部从 `dsh-tui` 删除。终端重命名保留:TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上把终端窗口标题设为 `<会话标题> — <配置标题>` —— 包括恢复的会话,其标题现在从日志回放而不是重新生成。
+session-title 服务是唯一的标题来源。TUI 不包含 `autoTitle` 配置、标题模型请求、闩锁、abort controller、提示词或输出上限。TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上调用 `runtime.terminal.setTitle`,传入 ` — `。同一条终端安全的 OSC 0 路径会处理配置的回退标题、恢复的会话和实时修订,既不重命名 tmux 窗口,也不增加另一套终端控制接口。
模型生成的标题是组合选择:`examples/tui-agent/cordis.yml`(以及脚本化 PTY fixture)挂载 `@deepseek-ai/dsh-session-title-first-message-llm`,它继承主请求的确切路由,用简短的模型摘要替换 spine 的确定性回退。未挂载该 provider 的部署保留 `dsh-agent-spine-demo` 内置 `SessionTitleService` 的回退标题。
@@ -20,6 +20,16 @@ tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自
**把 auto-title 的提示词和截断移植为服务的第三个 provider。** first-message-llm provider 已经存在,节奏相同,且有经过评审的提示词契约、持久的请求记录和替换围栏;再造一个近乎相同的 provider 纯属重复。
+**只使用截断后的首条提示词,或只使用模型标题。** 确定性回退可以立即且免费地提供标题,而可选模型 provider 可以提升质量,不会延迟主轮次。强制采用任一种策略都会移除这项部署选择。
+
+**让模型标题成为 TUI 默认行为,或为此阻塞第一个轮次。** 成本与路由归组合所有,辅助标题的延迟不得进入交互关键路径。TUI 只消费已接受的状态,不拥有生成策略。
+
+**重命名 tmux 窗口,或使用另一种终端转义序列。** 不予采纳,因为现有终端适配器的 OSC 0 路径可以标记窗格或标签页,无需取得 tmux 归属,也无需增加第二套控制 API。
+
+## 验证
+
+TUI 测试锁定恢复后和实时的 `session/title` 消费、终端安全的标题渲染、配置的回退标题,以及不存在 TUI 自有模型路径。无密钥 PTY 冒烟测试启动真实组合,接收已记录的 provider 标题,并观察由此产生的终端标题。[日志承载标题决策](../feature/2026-07-21-log-backed-session-titles.md)拥有 provider、持久化、恢复、fork、取消和陈旧完成结果的覆盖。
+
## 影响
-标题管线归一:持久、可回放、对所有消费者可见,并由服务对过期完成设防。TUI 削减约 90 行及其 `llm` 流式路径。代价是模型质量的标题现在需要在组合中挂载 provider 插件 —— 这是叶配置选择,不是 TUI 默认值 —— 且终端标题形状从裸模型摘要变为日志路径一贯使用的 `<标题> — <产品>` 后缀形式。被取代的 auto-title Agent Note 携带指向本文的指针。
+唯一的标题管线持久、可回放、对所有消费方可见,并由服务防止陈旧完成结果生效。TUI 不再有 `llm` 流式标题路径。若要提升模型标题质量,组合中必须挂载 provider 插件;未挂载的部署保留确定性回退。终端标题始终采用 ` — ` 后缀形式。
diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml
index 2901a3b813..fe203d8f0f 100644
--- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml
@@ -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
-2026-07-23-acp-automation-only-protocol.md: 2a92f306065b348764f35e1e63f0d7750a636372
-2026-07-23-acp-automation-only-protocol.zh.md: 5889d668310e3bd63f3934a39d4e5250f83f063d
+2026-07-23-acp-automation-only-protocol.md: 0fe2fc27a963d21e8a24c1682359ab3bc9e7af48
+2026-07-23-acp-automation-only-protocol.zh.md: 0a471f0bf1b12e835660cdce2d2a2acd761e1b89
diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md
index 2a92f30606..0fe2fc27a9 100644
--- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md
+++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md
@@ -18,20 +18,26 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble
The bridge emits only committed `assistant/message` text. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation.
-One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically.
+One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically.
The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-interaction services for ACP. SDK scaffolding likewise treats `ask_user_question` as TUI-only.
+The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output.
+
Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle.
## Snapshot boundary
The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions.
+Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiation, fresh-session creation, text and resource-link flattening, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement, per-session cancellation, failed transport closure, ACP-only reload cleanup, and teardown quiescence. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant.
+
## Alternatives considered
**Keep ACP as an editor UI until Web reaches parity.** Rejected because it leaves two interactive contracts to evolve and keeps editor conventions in the automation boundary.
+**Keep the earlier editor bridge behind disciplined seams.** Rejected even though that bridge correctly used interface services, tool-owned render intents, approval and user-interaction answerers, harness-owned execution, and a stdout-pure composition. Its terminal cards were capability-gated, display-only Zed `_meta` projections with a text fallback rather than ACP `terminal/create`, so shell execution never left the harness. The projection derived each display terminal id from the stable per-call id to prevent collisions and recovered exit code or signal from the rendered status markers because the pure result presenter received content blocks rather than a structured exit; marker round-trip tests and an explicit no-capability `console` fallback test pinned both contracts. Those boundaries were coherent but could not make editor cards, session navigation, configuration pickers, and human elicitation belong in an automation protocol.
+
**Replace ACP with a private subagent RPC.** Rejected because ACP already supplies a typed, interoperable process protocol and is used by the out-of-process subagent backend.
**Remove machine permission requests with the other interaction features.** Rejected because an automated parent must answer a child agent's one-shot policy decision; this is control flow between agents, not presentation.
diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md
index 5889d66831..0a471f0bf1 100644
--- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md
@@ -18,20 +18,26 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控
桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。
-保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。
+保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中的精确 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为失败关闭的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。
应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。SDK 脚手架同样将 `ask_user_question` 视为 TUI 专属功能。
+传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。
+
断开连接与插件 dispose(资源释放)共享同一个经记忆化处理的静止边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。
## 快照边界
ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。
+协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、精确 agent 权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍属于覆盖豁免,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。
+
## 考虑过的替代方案
**在 Web 达到同等能力前,继续将 ACP 作为编辑器 UI。** 不予采用,因为这会留下两套需要演进的交互契约,并使编辑器约定继续存在于自动化边界中。
+**通过严格的 seam 保留早期编辑器桥接层。** 不予采用,尽管该桥接层正确使用了接口服务、工具自有的 render intent、审批与用户交互应答者、harness 自有执行,以及保持 stdout 纯净的组装。其终端卡片是经过能力门控、仅用于展示的 Zed `_meta` 投影,并提供文本回退,而非使用 ACP `terminal/create`,因此 shell 执行从未离开 harness。该投影从稳定的逐调用 id 派生每个展示用终端 id,以避免冲突;由于纯结果展示器接收的是内容块,而不是结构化退出信息,它会从渲染后的状态标记中恢复退出码或信号。标记往返测试和显式的无能力 `console` 回退测试锁定了这两项契约。这些边界保持一致,却无法让编辑器卡片、会话导航、配置选择器和面向人类的询问成为自动化协议应有的职责。
+
**用私有 subagent RPC 替换 ACP。** 不予采用,因为 ACP 已经提供类型化、可互操作的进程协议,并由跨进程 subagent 后端使用。
**随其他交互功能一起移除机器权限请求。** 不予采用,因为自动化父 agent 必须回答子 agent 的一次性策略决策;这是 agent 之间的控制流,而不是展示层。
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
index d89275da06..65e49bd193 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
@@ -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
-2026-06-22-subagent-snapshot-replay.md: 6e5e94308ed145b83160146fd9e9ef023f2dde5d
-2026-06-22-subagent-snapshot-replay.zh.md: 82bb7d0735c7dbf918941d00ee4c59498cc59085
+2026-06-22-subagent-snapshot-replay.md: 8cd7bc86e07af9ed274c18574b575b9070854e88
+2026-06-22-subagent-snapshot-replay.zh.md: eae78129405fedd03c2c579845c07c6e5694cc30
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
index 6e5e94308e..8cd7bc86e0 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
@@ -11,7 +11,7 @@ The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subproce
It was built for ONE session per process, and that assumption is wired into two places:
- **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa).
-- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped.
+- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log, so the child's transcript was silently dropped.
This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This Agent Note is that stacked follow-up.
@@ -39,7 +39,7 @@ The alternative considered and rejected was a **call-ordered merge of the parent
### 3. The harness harvests every log, primary-first
-`harvestSessionLogs` collects every `.jsonl` across every cwd bucket under the sessions root (the JSONL backend puts a parent and its same-cwd child in the same bucket), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed.
+`harvestSessionLogs` recursively collects every fixed `session.jsonl` transcript under the sessions root (the JSONL backend gives each parent and child its own project/session directory), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed.
### 4. Scenarios
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
index 82bb7d0735..eae7812940 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
@@ -11,7 +11,7 @@ Status: implemented
该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处:
- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent(智能体)和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。
-- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript(文本记录)被静默丢弃。
+- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行并拥有自己的日志,因此子 agent 的 transcript(文本记录)被静默丢弃。
这就是 [subagent seam Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。
@@ -39,7 +39,7 @@ Status: implemented
### 3. harness 收集所有日志,主会话优先
-`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`(JSONL 后端将父会话与同 cwd 的子会话放在同一个 bucket),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session..jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。
+`harvestSessionLogs` 递归收集 sessions 根目录下所有固定命名为 `session.jsonl` 的 transcript(JSONL 后端为每个父会话和子会话分别提供独立的项目/会话目录),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session..jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。
### 4. 场景
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
new file mode 100644
index 0000000000..ef389fecb3
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-24-web-gui-browser-e2e-lane.md: fb870c4bb2c85d8be7ac11f9f29f05bf24f446a4
+2026-07-24-web-gui-browser-e2e-lane.zh.md: c43e526d27fd4d8cf4f774e8a03480930682041d
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
new file mode 100644
index 0000000000..fb870c4bb2
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
@@ -0,0 +1,90 @@
+# Agent Note: Keyless browser e2e lane for the web GUI
+
+Status: implemented
+
+English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md)
+
+## Problem
+
+The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits.
+
+## Decision
+
+`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are two additive `dsh-llm-replay` surfaces (`paceMs`, `ReplayHandle`).
+
+### Scaffold: `apps/web/tests/scaffold.ts`
+
+A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners.
+
+`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure.
+
+Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER.
+
+`seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair).
+
+### Determinism rules
+
+The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open).
+
+No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
+
+Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors.
+
+### Expected outputs
+
+One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping.
+
+The typecheck plane split is structural: the three files that boot the host spine (`scaffold`, `replay-round-trip.e2e`, and `seeded-history.e2e`) are excluded from the client-registered `apps/web` project. Those files and their shared `support.ts` are included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges.
+
+### Modes and fixtures
+
+`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred.
+
+### Scenarios
+
+1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (the bash call's durable result is exactly `WEB_E2E_OK\n`, completed `turn/end`, >10 chunk events).
+2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed.
+
+### CI stance
+
+The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix).
+
+## Prior art
+
+Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural.
+
+## Alternatives considered
+
+**Browser-network SSE interception (`page.route`).** Rejected: `route.fulfill` cannot stream, so incremental token rendering is unexercisable and the server-side SSE/backpressure/close path — where both confirmed P0s hid — goes untested.
+
+**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job.
+
+**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction.
+
+**Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point.
+
+**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios a factory generalizes from one consumer while the genuinely shared logic is already exported from gated packages. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free.
+
+**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty.
+
+**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths.
+
+**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose.
+
+**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement.
+
+**A client `data-dsh-busy` settled signal.** Deferred: the multi-condition settled polls proved sufficient at two scenarios and the host-side `whenIdle` barrier does the heavy lifting. Re-entry trigger: the first settled-poll flake, or a scenario needing a state the DOM does not expose.
+
+## Testing
+
+The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites both aria goldens keylessly. `paceMs` validation, pacing floor, abort-during-pace, and both `assertConsumed` failure shapes are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`.
+
+## Deferred
+
+- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors.
+- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`).
+- **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses.
+
+## Consequences
+
+The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided.
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
new file mode 100644
index 0000000000..c43e526d27
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
@@ -0,0 +1,90 @@
+# Agent Note: Web GUI 的无密钥浏览器 e2e 车道
+
+Status: implemented
+
+[English](2026-07-24-web-gui-browser-e2e-lane.md) | 中文
+
+## 问题
+
+Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个文本记录(transcript)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。
+
+## 决策
+
+`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量只有 `dsh-llm-replay` 的两处增量接口(`paceMs`、`ReplayHandle`)。
+
+### Scaffold:`apps/web/tests/scaffold.ts`
+
+一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。
+
+`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。
+
+无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。
+
+`seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。
+
+### 确定性规则
+
+回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。
+
+不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
+
+每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。
+
+### 预期输出
+
+每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。
+
+类型检查平面切分是结构性的:启动 host 主干的三个文件(`scaffold`、`replay-round-trip.e2e` 和 `seeded-history.e2e`)被排除出注册在 client 侧的 `apps/web` 工程。这三个文件及其共享的 `support.ts` 逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。
+
+### 模式与 fixture
+
+`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。
+
+### 场景
+
+1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(这次 bash 调用的已持久化工具结果严格等于 `WEB_E2E_OK\n`、完成的 `turn/end`、>10 个分片事件)。
+2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。
+
+### CI 立场
+
+车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)。
+
+## 业界先例
+
+调研了 AI 聊天/agent web UI 与 mock 层(LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlit;Playwright HAR/route、MSW、Polly/nock、WireMock、aimock)。自有后端的应用的主流成熟架构是:真实后端 seam 后放一个进程内伪造/回放模型,下游全部真实(LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型;ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`;continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体;playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现(LibreChat 默认 10ms 附慢速档;ai-chatbot 500ms);CI 里的真实模型会腐烂(open-webui 的套件长出 120 秒超时,先被禁用后被删除);会话在持久化层以受控时间戳播种(LibreChat 直插回拨时间的 Mongo 文档;langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixture(aimock)与前端层 socket 历史发射(OpenHands MSW)——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。
+
+## 曾考虑的替代方案
+
+**浏览器网络层 SSE 拦截(`page.route`)。** 已否决:`route.fulfill` 无法流式输出,增量 token 渲染无从检验,且服务端 SSE/背压/关闭路径——两起已实证 P0 的藏身处——完全失测。
+
+**`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。
+
+**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shell;client API seam 以下按构造即失测。
+
+**用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。
+
+**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且两个场景就上工厂是从单一消费方过度泛化,真正共享的逻辑已从受门禁的包中导出。重启条件:出现第二个 web 形态消费方,或 ≥6 个场景的内联分支被证实各自漂移;届时包边界将画在无浏览器一侧。
+
+**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。
+
+**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。
+
+**为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。
+
+**以真实模型浏览器测试充当无密钥车道。** 已否决:按构造即不确定;被调研的前车之鉴(open-webui)长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。
+
+**客户端 `data-dsh-busy` 安定信号。** 暂缓:两个场景下多条件安定轮询已经够用,host 侧 `whenIdle` 屏障承担了重活。重启条件:第一次安定轮询抖动,或某场景需要等待 DOM 不暴露的状态。
+
+## Testing
+
+车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行两个场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写两份 aria 预期输出。`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。
+
+## 暂缓
+
+- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。
+- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。
+- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。
+
+## 后果
+
+Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归。
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml
new file mode 100644
index 0000000000..c9cebc9db9
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306
+2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
new file mode 100644
index 0000000000..0795f71f0e
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
@@ -0,0 +1,41 @@
+# Agent Note: Scriptable LLM wire fault server
+
+Status: implemented
+
+English | [中文](2026-07-25-scriptable-llm-wire-fault-server.zh.md)
+
+## Problem
+
+Adapter unit tests use local HTTP servers to classify individual provider failures, while retry tests use an in-process scripted `LlmAdapter` to prove closed-step recovery. Neither boundary provides a reusable server for running the shipping HTTP adapter, agent loop, and retry policy together, and neither lets a developer point an existing app at deterministic transport faults by changing only its base URL and API key.
+
+Connection refusal, a reset before the first event, clean EOF without `[DONE]`, a valid content-less completion, and a reset after partial output have different adapter and recovery outcomes. Treating them as one generic mock failure hides whether the provider boundary preserved the distinction and whether failed chunks remained outside committed model history.
+
+## Decision
+
+`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
+
+Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection.
+
+The `random` script entry performs a new weighted selection for every request. The server exposes and logs its unsigned 32-bit seed, accepts caller-supplied relative weights, and ships a success-heavy stress profile that mixes transport, protocol, provider, timeout, and semantic-empty outcomes. The profile is configurable test pressure rather than an estimate of production incident frequency; `connection_refused` remains outside the request-level pool.
+
+The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, idle timeout, and a valid content-less completion recover under the existing default policy; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies.
+
+## Verification
+
+Package tests exercise every request behavior, split UTF-8 request decoding, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing and delay bounds, IPv6 base URLs, random seed reproducibility, weight validation, single-result telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, semantic-empty recovery, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter.
+
+## Alternatives considered
+
+**Implement the server in Python** — rejected because Node's standard HTTP and socket APIs expose every required fault, while TypeScript keeps the server, CLI parser, tests, package build, lint, and coverage inside the repository's existing toolchain. A second runtime would add environment and subprocess dependencies without increasing wire isolation.
+
+**Keep separate inline mock servers in adapter tests** — rejected because those fixtures cannot be launched by an existing app and would duplicate behavior sequencing, randomization, telemetry, and connection cleanup across suites. A support package gives tests a shared implementation without promoting it to product API.
+
+**Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise.
+
+**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs.
+
+## Consequences
+
+Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history.
+
+The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md
new file mode 100644
index 0000000000..a0e3c98d72
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md
@@ -0,0 +1,41 @@
+# Agent Note: 可脚本控制的 LLM(大语言模型)协议层故障服务器
+
+Status: implemented
+
+[English](2026-07-25-scriptable-llm-wire-fault-server.md) | 中文
+
+## 问题
+
+适配器单元测试使用本地 HTTP 服务器对各类提供方故障逐一分类,重试测试则使用进程内的脚本化 `LlmAdapter` 证明已关闭步骤的恢复能力。这两个边界都无法提供可复用的服务器,以便同时运行交付版本的 HTTP 适配器、agent loop(智能体循环)和重试策略;开发者也无法仅修改现有应用的 base URL 与 API key,就让应用连接到确定性的传输故障。
+
+连接遭拒、首个事件前连接被重置、未收到 `[DONE]` 即正常 EOF、合法但无内容的完成,以及输出部分内容后连接被重置,会产生不同的适配器与恢复结果。把它们统一视为普通 mock 故障,会掩盖提供方边界是否保留了这些区别,以及失败请求的分片是否确实没有进入已提交的模型历史。
+
+## 决策
+
+`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。
+
+请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
+
+脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。
+
+服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置、空闲超时以及合法的无内容完成均可恢复;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。
+
+## 验证
+
+包测试覆盖所有请求行为、跨分片 UTF-8 请求解码、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析及延迟边界、IPv6 base URL、随机 seed 可复现性、权重校验、单结果遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、语义空结果恢复、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。
+
+## 曾考虑的替代方案
+
+**使用 Python 实现服务器**:不予采纳。Node 的标准 HTTP 与 socket API 足以暴露所有所需故障,而 TypeScript 可以让服务器、CLI 解析器、测试、包构建、lint 和覆盖率全部留在仓库现有工具链中。引入第二套运行时会增加环境与子进程依赖,却不能增强协议隔离。
+
+**在适配器测试中继续使用各自独立的内联 mock 服务器**:不予采纳。这些 fixture(测试前置数据)无法作为独立服务器启动并供现有应用连接,还会让不同测试套件重复实现行为编排、随机化、遥测和连接清理。支持包让测试共享同一套实现,又不会将其提升为产品 API。
+
+**仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。
+
+**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。
+
+## 后果
+
+开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。
+
+服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
new file mode 100644
index 0000000000..ad6c575d1e
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799
+2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
new file mode 100644
index 0000000000..87077b3fd3
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
@@ -0,0 +1,130 @@
+# Agent Note: Client Settings, Locale, and Theme layering
+
+Status: proposed
+
+English | [中文](2026-07-25-client-settings-locale-theme.zh.md)
+
+## Problem
+
+The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities.
+
+## Proposal
+
+**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell is a pure composition surface: it only declares slots and renders the chrome structure — zero copy, no locale dependency, and neither importing nor enumerating any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). Content that belongs to no single feature (the trigger/title/close chrome copy, the General directory with its skeleton rows, the `settings` dictionary) is owned by `ui-settings-general` — the owner of the ownerless copy, not a feature satellite package.
+
+The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares four slots: `settings.trigger` / `settings.header` / `settings.close` (chrome content seats, single) and `settings.section` (top-level pages, list). Accessible names all resolve from slot content: the trigger's accessible name is its text content, the dialog points at the header content node via aria-labelledby, and close is a visually hidden text seat. Each section is contributed by a feature plugin; the shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by `ui-settings-general` (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order.
+
+The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind.
+
+`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default).
+
+Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services.
+
+The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition.
+
+The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results.
+
+### First-phase registration surfaces
+
+| Registration surface | Owning plugin | First-phase content |
+|---|---|---|
+| chrome content (trigger/header/close) | `ui-settings-general` | Settings entry-row icon and copy, panel title, close hidden text |
+| General section (order 0) | `ui-settings-general` | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration |
+| Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch |
+| Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) |
+| Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package |
+| Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) |
+
+The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — the chrome plus the General skeletons live in `ui-settings-general`'s `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`).
+
+### Slot topology
+
+```text
+root
+└─ sidebar
+ └─ sidebar.settings single/root
+ └─ ui-settings(壳,零文案)
+ ├─ settings.trigger single/root ui-settings-general 注册
+ ├─ settings.header single/root ui-settings-general 注册
+ ├─ settings.close single/root ui-settings-general 注册
+ └─ settings.section list/root
+ ├─ general (order 0) ui-settings-general 注册
+ │ └─ settings.general.item list/root
+ │ ├─ language (0) locale 注册
+ │ └─ appearance (10) ui-theme 注册
+ └─ models (order 10) ui-models 注册
+```
+
+Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
+
+### Future work: promote slot declarations to first-class injectable waits
+
+`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form.
+
+### Service contracts
+
+```ts
+export type ThemePreference = 'light' | 'dark' | 'system'
+
+export interface ThemeDefinition {
+ id: string
+ colorScheme: 'light' | 'dark'
+ tokens: Record
+}
+
+export interface ThemeSnapshot {
+ preference: ThemePreference
+ active: ThemeDefinition // system 已解析为具体 light/dark 定义
+ themes: readonly ThemeDefinition[]
+ revision: number
+}
+
+export interface LocaleDefinition {
+ id: 'zh' | 'en'
+ label: string
+}
+
+export interface LocaleSnapshot {
+ active: 'zh' | 'en'
+ locales: readonly LocaleDefinition[]
+ revision: number
+}
+
+export interface Events {
+ /** @param snapshot - Current locale registry snapshot. @mode emit */
+ 'locale/change'(snapshot: LocaleSnapshot): void
+ /** @param snapshot - Current theme registry snapshot. @mode emit */
+ 'theme/change'(snapshot: ThemeSnapshot): void
+}
+```
+
+Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the only write entry points, and an unknown id fails.
+
+## Alternatives considered
+
+**Having the app shell subscribe to preferences centrally and re-render the root slot tree.** A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell.
+
+**The theme service mutating the DOM directly.** The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary.
+
+**Resolving system in the Layout presenter.** The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot.
+
+**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin.
+
+**A per-feature `ui-settings-*` satellite package for each section.** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and the satellite packages depending back on the locale/theme services form an intermediate layer that exists purely for the package split. Under feature-owner self-registration that layer does not exist: preference rows ship with their feature packages, and `ui-settings-general` takes in only the ownerless copy (the chrome and the General skeletons), carrying no feature's settings surface.
+
+**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding.
+
+## Acceptance criteria
+
+- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger.
+- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes.
+- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events.
+- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders.
+- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter.
+- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately.
+- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes.
+- The overlay closes via the close button, a mask click, and ESC.
+
+## Risks
+
+The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR.
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
new file mode 100644
index 0000000000..a64a4afdf6
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
@@ -0,0 +1,130 @@
+# Agent Note: Client Settings、Locale 与 Theme 分层
+
+Status: proposed
+
+[English](2026-07-25-client-settings-locale-theme.md) | 中文
+
+## Problem
+
+浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。
+
+## Proposal
+
+**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。
+
+Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。
+
+Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。
+
+`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。
+
+功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。
+
+Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。
+
+Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。
+
+### 首期注册面
+
+| 注册面 | 属主插件 | 首期内容 |
+|---|---|---|
+| chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 |
+| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 |
+| Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 |
+| Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) |
+| Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 |
+| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) |
+
+首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。
+
+### Slot topology
+
+```text
+root
+└─ sidebar
+ └─ sidebar.settings single/root
+ └─ ui-settings(壳,零文案)
+ ├─ settings.trigger single/root ui-settings-general 注册
+ ├─ settings.header single/root ui-settings-general 注册
+ ├─ settings.close single/root ui-settings-general 注册
+ └─ settings.section list/root
+ ├─ general (order 0) ui-settings-general 注册
+ │ └─ settings.general.item list/root
+ │ ├─ language (0) locale 注册
+ │ └─ appearance (10) ui-theme 注册
+ └─ models (order 10) ui-models 注册
+```
+
+section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
+
+### Future work:坑位声明升格为可 inject 的一等等待物
+
+`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:` 服务(value 为坑位 spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。
+
+### Service contracts
+
+```ts
+export type ThemePreference = 'light' | 'dark' | 'system'
+
+export interface ThemeDefinition {
+ id: string
+ colorScheme: 'light' | 'dark'
+ tokens: Record
+}
+
+export interface ThemeSnapshot {
+ preference: ThemePreference
+ active: ThemeDefinition // system 已解析为具体 light/dark 定义
+ themes: readonly ThemeDefinition[]
+ revision: number
+}
+
+export interface LocaleDefinition {
+ id: 'zh' | 'en'
+ label: string
+}
+
+export interface LocaleSnapshot {
+ active: 'zh' | 'en'
+ locales: readonly LocaleDefinition[]
+ revision: number
+}
+
+export interface Events {
+ /** @param snapshot - Current locale registry snapshot. @mode emit */
+ 'locale/change'(snapshot: LocaleSnapshot): void
+ /** @param snapshot - Current theme registry snapshot. @mode emit */
+ 'theme/change'(snapshot: ThemeSnapshot): void
+}
+```
+
+Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。
+
+## Alternatives considered
+
+**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。
+
+**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。
+
+**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。
+
+**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。
+
+**按功能为每个 section 单开 `ui-settings-*` 卫星包。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且卫星包反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:preference 行随功能包交付;`ui-settings-general` 只收无主文案(chrome 与 General 骨架),不承载任何功能的设置面。
+
+**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。
+
+## Acceptance criteria
+
+- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。
+- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。
+- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。
+- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。
+- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。
+- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。
+- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。
+- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。
+
+## Risks
+
+slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。
diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
similarity index 64%
rename from .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml
rename to .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
index 3ed957d231..7dcdab8078 100644
--- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml
+++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
@@ -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
-2026-07-20-tui-startup-slogans.md: a2a22baafddd08145cec0d03b65ee56b2f8114b1
-2026-07-20-tui-startup-slogans.zh.md: 58fa5790f315845f27b810d62658bd79428b519b
+2026-07-06-recallable-compaction.md: ed5491e642ea7ac99fd9f4ba071a61e655f968d3
+2026-07-06-recallable-compaction.zh.md: 4060df2c2550f9ea3287adfb51d097c1a60baf71
diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
index 3f54030f9d..ed5491e642 100644
--- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
+++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
@@ -2,6 +2,8 @@
Status: proposed
+English | [中文](2026-07-06-recallable-compaction.zh.md)
+
## Problem
Compaction is a one-way door. The summary the model sees carries no reference to what it shadows — the `shadowedRange` provenance lives only on the log-only `compact/summary` event — and no tool lets the model read a shadowed span back. Whatever the summarizer drops is gone from the model's reachable world, even though the append-only log holds every byte. Repeated compaction compounds this: the head checkpoint is rewritten every pass, so the request prefix takes a full prompt-cache miss each time, and earlier summaries are re-summarized generation after generation.
diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
new file mode 100644
index 0000000000..4060df2c25
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
@@ -0,0 +1,110 @@
+# Agent Note: 可回溯压缩(compaction):索引检查点、状态检查点与会话内历史回溯
+
+Status: proposed
+
+[English](2026-07-06-recallable-compaction.md) | 中文
+
+## 问题
+
+压缩是一扇单向门。模型看到的摘要没有指向被其遮蔽内容的引用,因为 `shadowedRange` 来源只存在于仅写入日志、模型不可见的 `compact/summary` 事件上,也没有工具能让模型重新读取被遮蔽的区段。即使仅追加日志仍保存每一个字节,摘要器丢弃的内容也会离开模型可触达的世界。重复压缩会进一步放大问题:每一轮都会重写头部检查点,因此请求前缀每次都会完全失去提示词缓存命中,而更早的摘要也会一代又一代地被重新摘要。
+
+根本原因是一个产物承担了两个互相冲突的角色。**索引** 需要冻结、按时间排序且成本低廉;模型的**工作记忆** 则需要全局视图、重新确定优先级并且可变。单一摘要无法同时胜任两者。
+
+主流编码 harness 都没有让模型在循环内回溯,而且调研过的实现均未让压缩感知前缀缓存。事件溯源会话具备持久原文、可按 seq 寻址和精确回放的特征,是支持这两项功能的天然底座。
+
+## 提案
+
+把检查点拆为两类,并让被遮蔽的历史重新可达。
+
+### 冻结的索引检查点
+
+新近变为陈旧的历史按确定性策略拆分为分片:向 `chunkTokens` 累积;使用 `toolPairingBalancedBefore`/`toolPairingBalancedAfter` 对齐边缘;优先选择轮次边界;在平衡允许的范围内,把最终边界放在尽量接近保留边界的位置,使尾随切片缩小到大约一个轮次。每个分片通过一次 `compactRegion` 调用压缩为一个**索引存根**(`stubTokens`,约 100–200 个 token):
+
+- 用两三行说明发生了什么;
+- 用一行关键词记录低频字面锚点,例如确切的错误字符串、值和配置键,并按类别分组;
+- 由代码组装页脚:`[checkpoint c: shadows conversation span #–#; originals retrievable via history_read]`。指针根据来源组装,绝不由模型编写。
+
+已经提交的存根永不重写,也绝不再次进入之后的压缩区域。存根调用采用分层输入:固定前导内容与逐字节相同的本轮开始状态检查点(该阶段所有调用共享的前缀);随后是先前所有已提交存根的关键词行,使新条目索引其分片的独特内容,而不是重复整个目录;再加最近一两个已提交存根以维持时间连续性;最后是切片本身。同一轮中的同级存根不作为输入,因为并发阶段禁止这种依赖,而与轮次对齐的边界已经维持局部连续性。状态检查点只作为背景,绝不能成为存根需要总结的材料。完全由回溯内容构成的切片只通过代码生成存根,即只写一行指针,不调用 LLM(大语言模型)。存根调用失败时采用相同降级方式:其切片获得一个仅包含代码指针的存根,本轮继续执行,使状态重写成为一轮中唯一的强制 LLM 依赖。
+
+### 状态检查点
+
+系统维护一份可变的工作记忆文档(最多一份;第一次压缩前为零份),位于所有存根之后、保留尾部之前。每一轮根据先前状态与本轮变为陈旧的内容重写它,成本为 O(previous + new);过程遵守摘要提示词中已有的「合并而不重复陈述」规则,并覆盖决策、当前状态、约束和后续步骤。它带有自己的页脚,其大小上限与当前摘要处于同一量级。
+
+膨胀保护会约束整轮操作:如果压缩后大小没有严格小于压缩前大小,就不提交任何内容,并继续当前轮次;本次尝试延后至积累更多陈旧历史后再进行。保护逻辑在两侧比较同一项度量:优先使用请求路径上提供方报告的用量;如果不可用,则两侧都回退为字符估算器。
+
+### 一轮的执行过程
+
+- 分片切片由表面位置范围表示。一轮分两个阶段运行:所有摘要调用先并发执行并在表面之外缓冲;随后严格从左到右提交区域,先提交各分片,最后提交尾随切片,使状态检查点通过连续的单节点替换落在所有存根之后。墙钟时间维持在接近一次摘要调用的水平。
+- 被取代的状态检查点会作为普通历史折入下一轮的第一个分片,不需要墓碑或新原语。其存根省略该状态,`history_read` 会把它渲染为 `[prior state checkpoint]`,并让页脚随渲染文本一同传递,使每个尾随切片都可通过两跳链路触达。
+- 范围选择会感知冻结边界:可压缩区段从最后一个已提交索引检查点之后开始;只有在不存在索引检查点时,才从表面头部开始。旧会话现有的头部检查点会被视为状态类检查点:其文本作为合并基线,其节点则像其他被取代状态一样折入历史。
+- 摘要阶段发生崩溃时不会提交任何内容;提交中途崩溃会留下一个从左到右的已提交前缀,恢复后的一轮从日志中最新的状态类 `compact/summary` 事件读取合并基线,并无条件提交剩余区域。恢复 `[stubs…][state][tail]` 的优先级高于缩减大小。
+
+### 回溯工具
+
+新增包(package)`@deepseek-ai/dsh-tool-recall`,它只是 `dsh-session` 与 `dsh-compact` 词汇之上的消费方,注册两个面向模型的工具:
+
+- `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。
+- `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。
+
+两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志是归档,`compact/summary` 来源是索引元数据,而这些工具是两者之上的读取路径。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compact-basic` 移入 `dsh-session`,供摘要器与工具共享。
+
+### 缓存与成本
+
+一轮后的请求前缀为 `[system][stubs…][state][tail]`。冻结存根在各轮之间逐字节稳定,因此缓存缺失从替换先前状态检查点的 token 才开始,规模保持 O(new chunks + state + tail),而当前实现会从位置零开始缺失。回溯输出落在尾部,不会改变前缀。每轮摘要输入大约是当前实现的两倍,另加一个 m·S 背景项;该成本受到 `chunkTokens` 下限(状态上限的小倍数)以及经过校验的 `stubTokens`/`chunkTokens` 比例上限约束。共享前缀输入布局依次为前导内容、逐字节相同的本轮开始状态、位于尾部的切片内容,使同级调用可以按缓存费率重复读取。
+
+### 打包方式
+
+该设计以新的后端 `dsh-compact-recallable` 交付,挂在现有 `ctx.compact` seam 上,并在已交付的示例配置中默认启用。`compact-basic` 保留为参考实现和该 seam 的设计对照,与成对 LLM 适配器的模式一致。seam JSDoc 中「最多一个自动生成的检查点,始终位于头部」这一条会放宽,改为说明两个后端各自的行为。
+
+### 与进行中工作的关系
+
+- **工具结果裁剪**(进行中的裁剪服务):其替换节点携带 `sourceEventSeqs`;同一注册表折叠会把经过裁剪的结果列为可回溯。它属于后续范围,两项工作互不阻塞。
+- **提供方 token 用量核算**(正在把压缩压力迁移至提供方报告用量的工作):为保护逻辑提供核算基础;本实现堆叠在它之后。
+- **「查询会话」backlog(待翻清单)条目**:它是跨会话的泛化方案;本 Agent Note(agent 决策记录)把范围限定在实时会话内,并选择工具名称与渲染方式,使该工作能够扩展本设计而不产生冲突。
+- **训练**:何时回溯属于学习到的行为。确定性页脚与关键词锚点为训练提供稳定目标,而回溯使用情况在会话日志中完全可见,可供轨迹导出;基准测试与 RL 设计由后训练侧推进。
+
+### 后续事项
+
+以下项目在评审期间已经明确,但会延后至观察结果证明需要时再实现:
+
+- 保护逻辑降级阶梯(通过代码汇总最早的存根前缀,保留页脚,已汇总 id 仍可作为回溯目标;随后在冻结边界后生成一份摘要):触发条件是观察到保护逻辑活锁或存根区域压力。
+- 存根输出回声检测(句子级 n-gram,豁免短字面量;先重试,再剥离):触发条件是观察到职责分工泄漏。
+- 定期使用分片原文刷新状态:触发条件是交接探针观察到漂移。
+- `stateFallbackThreshold`(存根数量低于阈值时使用完整细节的状态提示词):触发条件是短会话回归。
+- 延迟注册回溯工具:触发条件是在从不进行压缩的会话中测得上下文开销。
+- 在 pre-step 分摊存根起草工作:一旦已经陈旧但尚未压缩的内容积累超过 `chunkTokens`,就在下一个 pre-step 起草该分片的存根(一个仅写入日志的草稿事件,在分片周围的上下文仍然存活时写入),让压缩轮提交草稿,而不是集中执行摘要。这是后台压缩的确定性、精确回放等价形式(Claude Code 会话记忆采用这种模式;OpenClaw 证明同步语义完全相同)。触发条件是观察到一轮延迟,或近实时起草带来的存根质量收益得到验证。
+- 拆分摘要模型;由模型选择分片边界;跨会话回溯;语义搜索回退:每项都必须由各自证据支持。
+- 更丰富的 `history_search` 查询形式:正则表达式,以及对日志 JSON 工具结果执行的结构化查询(sql/jq 风格,或由 agent 针对索引存储编写查询)。触发条件是观察到搜索漏检;首版先交付字面量匹配,使回溯路径保持为日志的纯函数。
+
+## 考虑过的替代方案
+
+- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR 方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。
+- **只保留冻结的全尺寸摘要,不设状态检查点**:不予采纳,因为永久前缀会无界增长、自我加速并最终发生颠簸,而且没有任何内容可以重新确定优先级。
+- **只保留纯存根,不设状态检查点**:不予采纳,因为这假定模型知道自己缺少什么,在面对未知的未知时会失败。
+- **由 LLM 老化/整合冻结分片**:不作为常规机制,因为摘要的摘要会丢失信息,并使冻结前缀频繁变化;其保留下来的形式是由代码汇总,且延后实现。
+- **把完整前缀作为分片摘要器输入**:不予采纳,因为成本为 O(N²);状态文档以 O(state) 提供相同背景。
+- **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱 seam。
+- **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略位于配置之后。
+- **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。
+- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。
+- **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。
+- **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。
+- **什么都不做(用 resume/fork 恢复)**:不予采纳,因为这会把恢复变成人工操作。
+
+## 验收标准
+
+- 长会话自动压缩后,每一轮完成时都会得到 `[stubs…][state][tail]`;先前存根在各轮之间保持逐字节相同;已提交存根绝不落入后续区域;被取代的状态检查点无需墓碑即可折入历史,渲染时带有标签,并可通过两跳链路触达和搜索。
+- 每个检查点的表面文本都以确定性页脚结束;页脚通过回放逐字节往返;状态检查点的来源记录其更宽的输入范围。
+- 在全部摘要就绪且保护逻辑通过同类核算前,不提交任何内容;保护失败不会提交任何内容,也不会让轮次失败;提交中途被终止后,下一次 pre-step 会恢复处理,从日志读取合并基线,并无条件提交状态区域以完成本轮;旧版头部检查点会被视为状态类。
+- `history_read` 在预算内渲染任意已记录检查点的区段,并提供可用游标;`history_search` 覆盖每个被遮蔽区段,返回带检查点 id 的片段与覆盖元数据,测试尤其要能找到只存在于被已取代状态检查点遮蔽区段中的内容,这是锁定尾随切片可达性的回归用例;两个工具都会拒绝非 agent 调用方,并对从未存在的 id 或遗留 `compact/start` 返回类型化错误;回溯内容作为普通 `tool/result` 出现;请求重建不变量会在同时包含压缩与回溯的会话上通过;一项无密钥快照场景端到端覆盖先压缩再回溯;工具 schema 与提示词章节在各轮之间逐字节相同。
+- 在长时间跨度 bench 套件中:任务成功率在预算相同的条件下不低于 `compact-basic`;交接保真探针(在一轮后重新陈述 K 项已知决策和约束)的得分不降低;每次运行都通过 dsh bench 报告流水线汇报回溯使用频率和命中有效性,并同时报告存根目录注意力度量与缓存命中遥测。
+- seam JSDoc、压缩能力 seam Agent Note、`architecture.md`,以及生成的工具、配置、持久化与模块图目录都在同一改动中更新;全部预算位于配置中;新源码目录具备逐文件 100% 覆盖率与 HMR(热模块替换)释放测试。
+
+## 风险
+
+- **回溯属于学习到的行为**:未经训练的模型会低频使用它,bench 报告会持续追踪这项差距,直至训练弥合问题。在此之前,状态检查点会让质量下限保持在当前摘要水平。
+- **未知的未知仍然存在**:如果某项细节既未出现在摘要中,也未出现在关键词中,就不会触发回溯。回溯把「即使已经怀疑也无法触达」变成「怀疑时可以触达」。
+- **存根目录会占用注意力**:每次请求中包含数十张稳定的索引卡,可能稀释模型关注点;验收标准中的 bench 度量会将其与 `compact-basic` 对比。
+- **成本**:每轮摘要输入大约是当前实现的两倍;短会话的成本和质量接近当前水平,而设计收益随会话长度增长。
+- **状态漂移与职责分工泄漏** 可以通过交接探针和存根评审观察;对应措施已列为后续事项。
+- **两个后端** 会增加维护接口;seam 契约和共享回溯消费方会约束该成本,bench 对比则用于逐步决定默认实现。
diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml
new file mode 100644
index 0000000000..d392c8cf22
--- /dev/null
+++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-13-human-review-skill-maintenance.md: 76391bd110b7a86b194a9340ffcf7cc4602d1d2e
+2026-07-13-human-review-skill-maintenance.zh.md: 67d68d07cc7f467a310b64d833a28da6f04bcc8e
diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md
index 6d4de215c9..76391bd110 100644
--- a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md
+++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md
@@ -2,6 +2,8 @@
Status: proposed
+English | [中文](2026-07-13-human-review-skill-maintenance.zh.md)
+
## Problem
The `dsh-code-review` skill records failure modes that require reviewer judgment, but one-off audits are expensive to repeat and easy to scope inconsistently. Treating every comment as a lesson produces checklist bloat; treating merge, thread resolution, or an author's “fixed” reply as proof of adoption promotes feedback that the final code may not implement. The maintenance process needs enough evidence and independent review to fail closed without requiring a webhook service, durable event state, or automatic repository promotion before the workflow has proven useful.
diff --git a/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md
new file mode 100644
index 0000000000..67d68d07cc
--- /dev/null
+++ b/.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.zh.md
@@ -0,0 +1,85 @@
+# Agent Note: dsh-code-review 的定期人工评审维护
+
+Status: proposed
+
+[English](2026-07-13-human-review-skill-maintenance.md) | 中文
+
+## 问题
+
+`dsh-code-review` skill(技能)记录需要评审人判断的失败模式,但一次性审计既难以重复,作用域也容易不一致。把每条评论都当作教训会让检查清单不断膨胀;把合并、讨论串已解决或作者回复「已修复」视为采纳证据,则会把最终代码可能并未落实的反馈提升为规则。维护流程需要足够的证据和独立评审,以便在证据不足时按不采纳处理,同时无需在工作流证明有用之前引入 webhook 服务、持久事件状态或自动仓库推广。
+
+## 提案
+
+在仓库外定期维护。一项保存在 skill 维护者机器上、而不提交到本仓库的私有工具,会针对刷新至 `origin/master` 的干净完整历史 checkout 运行。预期的调度器每天运行,并使用两天的 UTC 重叠窗口;手动运行可以通过另一个 `--since` 时长或重复的 `--pr` 参数指定显式集合。扫描相对于当前 skill 是幂等的,不存储仓库游标。推广时唯一会变更的仓库文件是 [.agents/skills/dsh-code-review/SKILL.md](../../../skills/dsh-code-review/SKILL.md);draft PR(Pull Request)携带来源概述,因此评审人无需私有适配器日志,也能审计源反馈与采纳证据。
+
+```mermaid
+flowchart TD
+ A["Maintainer or scheduler runs the tool on origin/master"] --> B["List PRs merged in the overlap window"]
+ B --> C["Collect pre-merge User feedback and final PR evidence"]
+ C --> D["Two reviewers verify provenance and adoption"]
+ D --> E{"Both confirm human-authored and adopted?"}
+ E -- "No" --> F["Exclude or retain as unresolved"]
+ E -- "Yes" --> G["Two reviewers classify against the current skill"]
+ G --> H["Draft a complete candidate from agreed guidance"]
+ H --> I["Two reviewers inspect the same skill diff"]
+ I -- "Blocking finding" --> J["Bounded revision loop"]
+ J --> I
+ I -- "Both approve" --> K["Run documentation and lint checks"]
+ K --> L["Leave a reviewed local working-tree diff"]
+```
+
+### 采集契约
+
+每个选定的 PR 都会在获取任何反馈前接受过滤:其 merge commit 必须是 `origin/master` 的祖先。merge commit 可达性是唯一的资格检查:直接 base 为功能分支的堆叠 PR,只要该 base 随后已经进入 master,就会被纳入,因为无论中间 stack 如何,评审人评论的代码此时已经在 master 上。工具还会解析落地 merge 的目标父级;无法重建的落地形态会记录到 `skipped-pulls.json` 并跳过。单个 PR 在预检、采集或证据收集时失败,只会被跳过,不会中止整次运行。如果时间窗口会超过 GitHub 搜索的 1,000 条结果上限,搜索阶段会明确失败,避免无提示地遗漏已合并 PR。采集阶段会完整读取内联评审评论、评审提交和 PR commit 的分页连接。不采集 PR 对话评论,因为强制推送后,GitHub 当前状态无法证明哪个仍然存在的 commit 位于评论之前,采纳契约因此必然会无条件排除这些评论。只有当 GitHub 报告 actor `type` 为 `User`,并且创建时间与最后编辑时间都严格早于 PR 合并时,工作流才接受已采集反馈;与合并同一时间戳的编辑视为合并后操作。评审提交使用 GraphQL 的 `lastEditedAt`,因为 REST 表示不含编辑时间。
+
+### 采纳证据
+
+每条反馈都携带稳定的来源 ID 和有界的变更证据。评审人的 `commit_id` 仍属于该 PR 时(强制推送场景无法确认即排除),工具会选择 committer 时间戳严格早于反馈的最新 PR commit 作为基线,而不是采用评审人点击的 commit,因为后者可能更旧。工具绝不会直接比较该基线与落地 merge:这种 diff 会混入不断前移的目标分支中的无关变更。相反,它会向采纳评审人提供两份 PR 专用 patch 快照。令 `B` 为反馈基线,`T` 为落地 merge 的目标父级,`M` 为落地 merge。反馈时快照是从 `merge-base(B, T)` 至 `B` 的树 diff;最终快照是从 `T` 至 `M` 的树 diff。因此,目标分支专有变更不会出现在任一 PR patch 中,而反馈后加入 PR 的变更只会出现在最终快照中。遭到强制推送的评审、早于全部现存 PR commit 的反馈,以及无法重建目标父级的落地形态,会在任何评审人看到之前确定性地归类为 `unclear`。合并状态、已解决讨论串、作者回复「已修复」或同文件编辑只是上下文,不是采纳证明;PR 作者自己的评论绝不会进入适配器,因为它们不可能构成对作者自身意见的采纳。
+
+### 双评审人分类与起草
+
+两个独立配置的评审适配器,会从来源(`human-authored`、`forwarded-automation` 或 `unclear`)和采纳情况(`adopted`、`rejected` 或 `unclear`)两个维度,对每个符合条件的条目进行分类。只有两个适配器都判定为 `human-authored` 加 `adopted` 的条目才会继续。采纳集合随后会针对当前 skill 接受第二次独立分类:候选项、已经覆盖、实现专用或并非反馈。单个条目即可符合要求,不要求重复出现。意见分歧会得到一次有界的重新评估;如果仍未解决,则继续保留在运行产物中。单个批次的适配器输出如果未通过 schema 或 id 校验,系统会在批次层按不采纳处理:其中的每条反馈都标记为 unclear 并路由到 `excluded`,而不是中止整次运行;有问题的原始输出会保存在该次运行的私有产物中,供调试使用。如果任一适配器在某项操作的任何非空批次中都没有返回有效结果,运行会以非零状态退出并发出失败记录,而不会报告「没有候选项」。
+
+主适配器只根据结构化的共同指引起草,绝不接收原始评审文本。根据适配器作者的契约,它保持无工具且只读:返回完整的候选文件内容,由工具校验后写入唯一目标。两个适配器随后评审同一份完整 skill diff;阻塞性问题会进入有界修订循环,而且两者必须批准同一版修订。工具会在运行文档和 lint 检查前,以及报告成功前,再次拒绝暂存改动和目标 skill 之外的编辑,因此检查或并发进程无法通过添加其他路径混入。失败时,工具使用尽力而为的比较并交换恢复自身写入,避免覆盖维护者的并发编辑。成功时,它保存一份候选资料包,其中包含源 `origin/master` commit、源 skill blob ID、已评审 diff、完整候选文件、源反馈 ID 与 URL、落地证据范围、适配器判定和检查结果;它绝不提交、推送、打开或合并 PR。
+
+### 评审适配器协议
+
+每个私有可执行文件从 stdin 接收有字节上限、带版本的 JSON 请求,并在 stdout 返回有字节上限且符合 schema 的 JSON。两个评审命令解析为逐字节相同的可执行文件时,工具拒绝运行;这是机械性的最低标准,保证主适配器与次适配器由独立提供方或模型驱动,仍是部署运维方的责任。`access` 与 `tools` 字段是适配器作者承担的契约标记,不是 OS 沙箱:评审子进程在清理后的环境中 spawn,其 `cwd` 指向私有运行目录而非仓库根目录;反馈包装在带随机数的 `` 块中,每个提示词都会要求模型把它视为数据;128 位随机数防止不受信任的正文伪造结束标签。每个子进程都采用有界、感知中止的进程树清理。适配器作者把每项操作实现为纯只读推理(inference);即使 `edit` 操作也只会在 JSON 中返回完整候选内容,由工具校验后写入唯一目标。每个生产 `git`/`gh`/检查命令同样在清理后的环境中 spawn,避免 pre-push 钩子的路由变量无提示地重定向维护工具。候选写入与失败回滚会针对最近一次写入内容使用尽力而为的比较并交换;回滚还会取消暂存目标,避免由适配器或检查暂存的候选项在失败运行后遗留并进入之后的 commit。
+
+### 推广契约
+
+推广辅助工具从刷新至 `origin/master` 的干净 checkout 开始;当前 skill blob 与资料包中记录的源 blob 不同时,它拒绝应用候选项。运维方随后重新运行维护分析,或手动把 diff 变基后重新评审候选项;辅助工具绝不会用陈旧的完整文件输出替换较新的 `SKILL.md`。应用仍然有效的候选项后,它会打开 draft PR,其正文列出源反馈 URL 或 ID、用作采纳证据的落地 commit 范围、来源运行、检查结果和任何运维方编辑。原始适配器提示词与响应保持私有,但仓库评审人会获得足够的来源信息,以判断每条提议规则是否确实来自已采纳的人类反馈。
+
+### 机制所在位置
+
+工具源码、适配器二进制文件、提供方凭据和预期的每日调度器保存在维护者机器上,不会提交到本仓库。本文规定协议,参考实现属于私有基础设施。该机制只服务于由单个运维方维护的一项 skill,因此,让机制编辑持续接受仓库评审的成本高于来源可追溯性的收益。如果该机制将来移交给第二位维护者,移交工作需要一篇后续 Agent Note(agent 决策记录)来修订本决策;任何接手者都应从运维文档 [docs/cookbook/maintaining-dsh-code-review.md](../../../../docs/cookbook/maintaining-dsh-code-review.md) 入手。
+
+## 考虑过的替代方案
+
+- **把工具放入本仓库。** 对单维护者作用域不予采纳:仓库维护开销(类型检查、lint、覆盖率与横切重构)会超过已提交来源信息的价值。未来移交时仍可重新考虑。
+- **记录每条反馈产生时的 PR head**:不予采纳,因为这需要持续运行的观察器、持久事件状态、重试和强制推送协调。定期维护会在可用时使用经过评审的 commit 证据,并在整 PR 证据范围过宽、无法确认时直接排除。
+- **持久化已处理 PR 游标**:不予采纳,因为带重叠的时间窗口扫描成本低廉,并且相对于当前 skill 天然幂等;游标状态反而带来恢复和漏事件问题。
+- **每次新评论都运行**:不予采纳,因为一轮评审会产生许多相关评论,并且缺少判断采纳情况所需的最终产物。
+- **把合并或讨论串解决视为采纳**:不予采纳,因为 PR 可能在反馈被拒绝、被取代或刻意不解决的情况下合并。
+- **自动创建或合并仓库改动**:不予采纳,因为工具首先需要通过有用的定期输出积累可信记录。维护者检查并通过普通仓库评审推广本地 diff。
+- **从已经修复的 bot 问题中学习**:不予采纳,因为来源契约限定为人类评审反馈。系统会在分析前按 actor 类型过滤,并通过来源评审排除转发自动化问题的人类账号。
+- **让同一个评审人既当作者又作最终裁决**:不予采纳,因为独立判定能在不受支持的概括进入 skill 之前暴露问题。
+
+## 验收标准
+
+从 `proposed/` 推广到 `implemented/`,需要在针对本仓库的真实端到端运行中观察到以下全部事实:
+
+- 私有工具从刷新至 `origin/master` 的干净 detached checkout 运行,并且要么报告「没有候选项」,要么只生成 `.agents/skills/dsh-code-review/SKILL.md` 的 working-tree diff。**2026-07-15 已观察:** 扫描 62 个已合并 PR,跳过 5 个(merge commit 不可达或采集超过 250 个 commit 的上限),考虑 426 条人类反馈,发现 0 个候选项。
+- 两个评审适配器独立配置(不同的提供方或模型),无需用户干预即可完成 analyze/adopt/review 流程。**2026-07-15 已观察:** 不同的主/次适配器约用 8 分钟完成采纳与分析;一次适配器 id 幻觉由批次级保守失败机制处理,没有中止整次运行。
+- 调度器在没有交互式终端的情况下触发工具,并通过持久通知通道把候选 diff(或「没有候选项」记录)送达运维方。
+- 一个受控采集场景会在反馈基线之后,向目标分支加入与反馈匹配的变更;评审证据排除该目标分支专有变更,同时保留后续由 PR 自身加入的变更。
+- 源 skill 改变后,推广辅助工具会拒绝候选项;仍然有效的候选项会按照上文定义的来源概述打开 draft PR。
+- 该工作流生成的至少一个候选 diff 会由运维方检查,并通过普通仓库 PR 评审推广到 `master`。该 PR 用以证明工作流能够把已采纳反馈转化为已交付的 skill 指引。
+
+## 风险
+
+- **根据 committer 时间戳推断因果关系。** 反馈 commit 基线通过比较 GitHub commit 时间戳与反馈创建时间戳选出;committer 时钟偏差与重写仍会留下误判采纳的残余窗口。与 GitHub 的 PR 事件流交叉比对可以进一步收紧,但需要采集定期工具作用域以外的事件。
+- **两个「非候选项」分类结果会直接路由到 `excluded`,不进入争议轮次。** 两个分类器都判断「不是候选项」,但对非候选原因意见不一时(例如 `covered` 与 `specific`),该条目会被排除而不是重新评估。两个分类器都同意它不会形成新的评审行为,所以争议轮次不会改变结果。
+- **超出字节哈希差异的双评审人独立性属于部署契约。** 两个命令解析为逐字节相同的可执行文件时,工具拒绝运行,但它无法验证两个不同包装层是否由不同提供方或模型驱动。运维方必须配置相互独立的主适配器与次适配器。
+- **候选写入与回滚使用尽力而为的比较并交换。** POSIX 上基于文件的比较并交换并不真正原子;窗口持续一个事件循环周期。该工具面向单用户定期维护,不考虑真正并发的编辑器。
+- **单维护者风险。** 由于机制位于单台机器,服务中断后,skill 维护会完全停止,直到运维方恢复服务,或通过一篇后续 Agent Note 把机制移交给新维护者。
diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml
index 45949d3fcf..b7b5632e38 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml
+++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml
@@ -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
-2026-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d
-2026-06-20-drop-acp-terminal-meta.zh.md: d29fd54618611f56fd071a0ee4a63bc207895d89
+2026-06-20-drop-acp-terminal-meta.md: 84b9028392f967e72f7b5585d013d669735631de
+2026-06-20-drop-acp-terminal-meta.zh.md: 6ac7ba46bce20bcf2593ef422057e0562f8a1557
diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
index 79da387ac1..84b9028392 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
+++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
@@ -1,14 +1,14 @@
# Agent Note: Drop ACP terminal `_meta` rendering
-Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients.
+Status: rejected — removing only Zed terminal metadata was rejected while ACP remained an editor bridge; automation-only ACP later removed the whole editor projection.
English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md)
## Problem
-The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`.
+The former ACP editor bridge implemented a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The current [render-intent decision](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) preserves the underlying rule that bash execution belongs in the harness and terminal cards are display-only. The later [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes the `_meta` projection, bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing from ACP. TUI and the Web host/client runtime retain the tagged presentation contract, while ACP no longer renders editor cards.
-The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration.
+At proposal time, the fallback path already existed: render the tool call and completed output as normal ACP content blocks. Non-Zed clients relied on that path, but the Zed terminal card was a target-client feature rather than speculative decoration.
## Proposal
@@ -22,10 +22,10 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2
- `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`.
- `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup.
- Bash result presentation no longer parses exit status for terminal pills.
-- The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded.
+- The [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) later removes ACP terminal cards and absorbs their execution-ownership rationale.
## What we give up
-Zed users lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They still see the command and output as plain content. That is a reasonable simplification while the ACP bridge is still unreleased and the `_meta` keys are a convention rather than a standard.
+Under this proposal, Zed users would lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They would still see the command and output as plain content. That was a reasonable simplification to consider while the ACP bridge was unreleased and the `_meta` keys were a convention rather than a standard.
diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md
index d29fd54618..6ac7ba46bc 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md
+++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md
@@ -1,14 +1,14 @@
# Agent Note: 移除 ACP(Agent Client Protocol)终端 `_meta` 渲染
-Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是有意设计的 Zed UX,同时为其他客户端保留普通 ACP 回退。
+Status: rejected — 在 ACP 仍是编辑器桥接层时,仅移除 Zed 终端元数据的方案被否决;后续仅面向自动化的 ACP 则移除了整个编辑器投影。
[English](2026-06-20-drop-acp-terminal-meta.md) | 中文
## 问题
-ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。
+原 ACP 编辑器桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。当前的 [render-intent 决策](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)保留了底层规则:bash 执行属于 harness,terminal 卡片只用于展示。后续的[仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)从 ACP 中移除了 `_meta` 投影、桥接状态、能力协商、终端 id、特殊 update 映射、文本回退测试和 exit-pill 解析。TUI 与 Web 宿主/客户端运行时保留带标签的展示契约,而 ACP 不再渲染编辑器卡片。
-回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。
+本提案提出时,回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。当时,非 Zed 客户端依赖这条路径,但 Zed 终端卡片是目标客户端的功能特性,而非推测性装饰。
## 提案
@@ -22,10 +22,10 @@ ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.t
- `TerminalRendering`、终端 id、终端 cwd 解析与 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。
- `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因未使用而删除。
- Bash 结果展示不再为终端 pill 解析退出状态。
-- 已实现的[富 ACP bash 渲染 Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。
+- [仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)后来移除了 ACP 终端卡片,并吸收了其中有关执行归属的决策依据。
## 放弃的内容
-Zed 用户将失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以纯内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键只是约定而非标准的阶段,这是合理的简化。
+如果采用本提案,Zed 用户会失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。但他们仍会以纯内容形式看到命令和输出。当时 ACP 桥接层尚未发布,且 `_meta` 键只是约定而非标准;在这种情况下,考虑这项简化是合理的。
diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml
index da93063670..14c7033185 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml
+++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml
@@ -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
-2026-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c
-2026-06-20-generic-tool-rendering.zh.md: 11386b87d845129950a8473eb1cf4ea6ce697ac8
+2026-06-20-generic-tool-rendering.md: a9ceb7a0e016b57295e3226e98a7fce51e49c21f
+2026-06-20-generic-tool-rendering.zh.md: 553c1caa23ed34eea5f113372d12a7381dc2488a
diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md
index 6fc610546d..a9ceb7a0e0 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md
+++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md
@@ -1,14 +1,16 @@
# Agent Note: Collapse tool-owned UI presentation
-Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path.
+Status: rejected — TUI and the Web host/client runtime consume the tagged render-intent union, so tool-owned presentation remains current even though ACP no longer projects it.
English | [中文](2026-06-20-generic-tool-rendering.zh.md)
## Problem
-Tools can define `presentCall()` and `presentResult()` callbacks that return `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flags the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal grew incrementally into a bag of optional fields. ACP then maintains pending call state to pair a result with the original args, creates replay-only presenters on `session/load`, and maps terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parses exit status back out of rendered text because the pure replay-safe presenter no longer has the structured `BashRunResult`.
+The optional-field bag and ACP editor mapping below were the proposal-time context for this rejection. The current contracts live in [the tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) and [automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md).
-The real first-party use is bash presentation for ACP. That is too little evidence to freeze a cross-package UI presentation API.
+Tools could define `presentCall()` and `presentResult()` callbacks that returned `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flagged the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal had grown incrementally into a bag of optional fields. ACP then maintained pending call state to pair a result with the original args, created replay-only presenters on `session/load`, and mapped terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parsed exit status back out of rendered text because the pure replay-safe presenter no longer had the structured `BashRunResult`.
+
+The real first-party use was bash presentation for ACP. That was too little evidence to freeze a cross-package UI presentation API.
## Proposal
@@ -28,8 +30,8 @@ As a smaller alternative, replace the current optional-field bag with one explic
## What we give up
-Bash loses its custom terminal-looking card and model-written description placement. The fallback remains reasonable: the command appears as tool input, and the output appears as text. Rich rendering should be designed when the product has enough UI/tool variety to justify a stable presentation contract.
+Under this proposal, Bash would lose its custom terminal-looking card and model-written description placement. The fallback would remain reasonable: the command would appear as tool input, and the output as text. Rich rendering would be designed when the product had enough UI/tool variety to justify a stable presentation contract.
## Related
-This is the broad version of [dropping ACP terminal metadata](2026-06-20-drop-acp-terminal-meta.md). If this Agent Note is accepted, that narrower Agent Note becomes unnecessary.
+The later [tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) implements the smaller alternative once multiple producer and consumer families provide enough evidence for the vocabulary. [Automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes ACP's editor projection without removing tool-owned presentation from TUI or the Web host/client runtime.
diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md
index 11386b87d8..553c1caa23 100644
--- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md
+++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md
@@ -1,14 +1,16 @@
# Agent Note: 收拢工具自有的 UI 展示逻辑
-Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP(Agent Client Protocol)目前仍需要现有的丰富呈现路径。
+Status: rejected — 尽管 ACP(Agent Client Protocol)已不再投影这套契约,TUI 与 Web 宿主/客户端运行时仍消费带标签 render-intent 联合类型,因此工具自有的展示仍然有效。
[English](2026-06-20-generic-tool-rendering.md) | 中文
## 问题
-工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。
+下文所述的可选字段集合与 ACP 的编辑器映射,是本提案遭否决时的背景。当前契约分别由[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)与[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)承载。
-真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包(package)UI 展示 API 的依据。
+当时,工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 已经逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。
+
+当时,真正的第一方用途是为 ACP 提供 bash 展示。这点证据不足以作为冻结一个跨包(package)UI 展示 API 的依据。
## 提案
@@ -28,8 +30,8 @@ Status: rejected — 工具拥有的呈现机制应等到出现更多真实工
## 放弃了什么
-Bash 失去其自定义的终端风格卡片和模型生成描述的放置位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应当在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。
+如果采用本提案,Bash 会失去其自定义的终端风格卡片和模型生成描述的放置位置。届时,回退方案仍然合理:命令会作为工具输入展示,输出会作为文本展示。只有当产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时,才会设计富展示。
## 相关
-这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 Agent Note(agent 决策记录)被接受,那个更窄的 Agent Note 就不再必要。
+后续的[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)在多类生产者与消费方为这套词汇提供充分依据后,实现了较小的替代方案。[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)移除了 ACP 的编辑器投影,但没有从 TUI 或 Web 宿主/客户端运行时中移除工具自有的展示。
diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md
index 43e2218085..12ff4c7e33 100644
--- a/.agents/skills/dsh-find-simplifications/SKILL.md
+++ b/.agents/skills/dsh-find-simplifications/SKILL.md
@@ -1,6 +1,6 @@
---
name: dsh-find-simplifications
-description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates and write proposed Agent Notes or inline TODO/FIXME/XXX notes for dead, duplicated, speculative, or over-built code surfaces; especially for requests like "find simplification Agent Notes", "look for unnecessary complexity", "audit for removal-style cleanups", or "fold worthwhile simplification ideas from another PR".'
+description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, or added-then-removed surfaces.'
---
# Finding DeepSeek Harness Simplifications
@@ -66,6 +66,22 @@ Reject or downgrade a candidate when:
- The removal would force unrelated churn without actually making the contract smaller.
- The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md).
+## Coalesce Superseded Agent Notes
+
+Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit.
+
+Follow the deletion rule in the [Agent Note contract](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain:
+
+1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof.
+2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial.
+3. For full supersession, move every unique rationale, alternative, consequence, shipped verification contract, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts.
+4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together.
+5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current.
+
+An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification contracts.
+
+Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone.
+
## Write The Agent Note
Create one file per durable proposal under `.agents/notes///yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links.
@@ -106,9 +122,11 @@ For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`
When opening or updating a PR, summarize:
-- How many Agent Notes and inline notes were added.
+- How many Agent Notes and inline notes were added, consolidated, retained as partial supersessions, or deleted.
- The main areas surveyed.
- What was intentionally excluded.
- Which checks passed.
+For each consolidation group, name the old and current owners, state the evidence for full supersession, and explain why deletion is safe. If an added-then-removed scan finds no qualifying note, report that result and the representative partial cases retained.
+
Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled.
diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md
index faf234ae7c..37b070e466 100644
--- a/.agents/skills/dsh-prose-standard/SKILL.md
+++ b/.agents/skills/dsh-prose-standard/SKILL.md
@@ -7,6 +7,8 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose
Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script.
+Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies.
+
## Inputs and exclusions
Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview.
diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md
index 8c28d4afda..c11079e0bc 100644
--- a/.agents/skills/dsh-translate-docs/SKILL.md
+++ b/.agents/skills/dsh-translate-docs/SKILL.md
@@ -17,7 +17,7 @@ When this skill fires and translations need to be written, do not translate your
These are authoritative; read them at the source so this skill never drifts out of sync.
-- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest.
+- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope, and exclusions.
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels).
- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift.
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's calibrated machine-consumed template. Agents using this skill do not render it; the terminology table is the only repository file the automated renderer injects, while this skill and `translation-rules.md` remain binding for agent-authored translations.
@@ -25,7 +25,7 @@ These are authoritative; read them at the source so this skill never drifts out
## Find the work
-- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok — the work list for a translation batch.
+- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them.
- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget.
## Triage by change type
@@ -56,7 +56,7 @@ Do not process every file the same way:
1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair.
2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have.
-3. New batch landed? Add the `.md` paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward.
+3. No manifest entry is needed for an ordinary document: every in-scope source requires a pair. Change [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) only when the owning policy documents a genuine generated, instructional, or bilingual-by-construction exclusion.
## Verify the mechanical and human halves
diff --git a/.gitignore b/.gitignore
index ae9b4b5ddd..d6b400aeb3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ pnpm-debug.log
.cache/
examples/*/*.jsonl
.sessions/
+.storages/
examples/*/.sessions/
coverage/
.doc-typecheck-*/
diff --git a/AGENTS.md b/AGENTS.md
index 64259784fb..a8f811229a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,6 +50,7 @@ Package groups: [packages/README.md](packages/README.md).
```sh
pnpm install # pnpm workspaces, node ^22.19 || >=24
+pnpm run clean # remove build outputs and safe residue from deleted packages
pnpm run test # vitest unit tests
pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src
pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
@@ -110,7 +111,8 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
-- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
+- **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
+- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) remains extensible for recurring distinctions.
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/apps/cli/README.i18n.yaml
similarity index 67%
rename from .agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml
rename to apps/cli/README.i18n.yaml
index 56333563f5..9769d5153b 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -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
-2026-07-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1
-2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152
+README.md: 83b60ea72580facbceb155d053223567fdc8446b
+README.zh.md: ca25547f4c5ed2627e692187e6ca9e947f1b3eac
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 4172eca836..83b60ea725 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -1,16 +1,20 @@
# `@deepseek-ai/dsh`
-The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
+English | [中文](README.zh.md)
+
+The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
+
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
The TUI surface:
-- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
-- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
+- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
+- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
-The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
+The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
new file mode 100644
index 0000000000..ca25547f4c
--- /dev/null
+++ b/apps/cli/README.zh.md
@@ -0,0 +1,27 @@
+# `@deepseek-ai/dsh`
+
+[English](README.md) | 中文
+
+`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
+
+Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
+
+TUI 界面:
+
+- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动;
+- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话;
+- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析;
+- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
+- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
+
+Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
+
+## 安装(开发机)
+
+将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建:
+
+```sh
+ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
+```
+
+`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。
diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml
index a85f2eaf2c..a30d5735f8 100644
--- a/apps/cli/cordis.yml
+++ b/apps/cli/cordis.yml
@@ -72,6 +72,22 @@
config:
root: './.sessions'
+- id: storage
+ name: '@deepseek-ai/dsh-storage'
+
+- id: storage-json
+ name: '@deepseek-ai/dsh-storage-json'
+ config:
+ root: './.storages'
+
+- id: storage-domain
+ name: '@deepseek-ai/dsh-storage-domain'
+ config:
+ backend: json
+
+- id: workspace
+ name: '@deepseek-ai/dsh-workspace'
+
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
@@ -205,8 +221,8 @@
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
-- id: i18n
- name: '@deepseek-ai/dsh-client-i18n'
+- id: locale
+ name: '@deepseek-ai/dsh-client-locale'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
@@ -214,9 +230,21 @@
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
+- id: ui-settings
+ name: '@deepseek-ai/dsh-client-ui-settings'
+
+- id: ui-settings-general
+ name: '@deepseek-ai/dsh-client-ui-settings-general'
+
+- id: ui-models
+ name: '@deepseek-ai/dsh-client-ui-models'
+
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
+- id: ui-workspace
+ name: '@deepseek-ai/dsh-client-ui-workspace'
+
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index a39ce726bb..571e71abcf 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -23,21 +23,24 @@
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
- "@deepseek-ai/dsh-client-i18n": "workspace:^",
+ "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
- "@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
@@ -50,6 +53,9 @@
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
+ "@deepseek-ai/dsh-storage": "workspace:^",
+ "@deepseek-ai/dsh-storage-domain": "workspace:^",
+ "@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
@@ -69,7 +75,9 @@
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
+ "@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
+ "commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},
diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index 52df4203b1..29e20b87b8 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -1,8 +1,8 @@
/**
- * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares
- * (config-tree boot wired for `dsh web` this round; TUI/headless migrate
- * later). Everything here is what must exist before the Loader runs: layered
- * env, the patch composition over the shipped cordis.yml (profile json + CLI
+ * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
+ * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
+ * Everything here is what must exist before the Loader runs: layered env,
+ * the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
*/
@@ -62,22 +62,30 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
-/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */
+/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shipped cordis.yml. */
configPath: string
- /** Whether to append the HMR row (the whole prod/dev difference). */
+ /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
dev: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
- /** --port when explicitly passed; undefined keeps the yml engineering default. */
+ /**
+ * Listen port override onto the webserver row. Web passes the --port flag
+ * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
+ * never collide — and the printed URL still opens the live session in a
+ * browser).
+ */
port?: number
+ /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
+ workspaceRoot?: string
}
/**
- * Boot driver for the config-tree `dsh web` shape: holds only what exists
- * independently of (and prior to) cordis — argv facts, the composed patch
- * set, and finally the root ctx.
+ * Boot driver for the config-tree dsh surfaces (web and headless share the
+ * one composition; the surfaces differ only in constructor facts): holds only
+ * what exists independently of (and prior to) cordis — argv facts, the
+ * composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
@@ -99,13 +107,13 @@ export class AppCLIEntry {
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
- if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot')
+ if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
- loadEnv('dsh web', resolveDshHome())
+ loadEnv('dsh', resolveDshHome())
}
/**
@@ -127,7 +135,7 @@ export class AppCLIEntry {
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
- throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
+ throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
@@ -135,6 +143,7 @@ export class AppCLIEntry {
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
+ if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
@@ -142,7 +151,7 @@ export class AppCLIEntry {
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
- if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`)
+ if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record, ...bag } }
})
}
@@ -173,8 +182,8 @@ export class AppCLIEntry {
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
private assertBoot(): void {
- installFailLoud('dsh web')
- assertEntriesLoaded(this.ctx, 'dsh web')
+ installFailLoud('dsh')
+ assertEntriesLoaded(this.ctx, 'dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
@@ -188,14 +197,14 @@ export class AppCLIEntry {
}
}
if (failures.length > 0) {
- throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+ throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
- if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`)
+ if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
@@ -214,7 +223,7 @@ export class AppCLIEntry {
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
- throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
+ throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record
}
@@ -225,7 +234,7 @@ export class AppCLIEntry {
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
- throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
+ throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
}
}
}
diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts
new file mode 100644
index 0000000000..9fd0f4d9bf
--- /dev/null
+++ b/apps/cli/src/args.ts
@@ -0,0 +1,143 @@
+/**
+ * Commander adapter for the `dsh` command-line entry: the one place argv is
+ * parsed and routed to a mode. `bin.ts` switches on the returned discriminant
+ * and dynamic-imports that mode's module. One program: the default (no
+ * subcommand) is the TUI/headless surface with option-only flags; `web` is a
+ * real subcommand. Commander owns `--help`/`--version` and parse errors — it
+ * prints and exits at the point of failure (a domain failure routes through
+ * `command.error`), so this returns only a resolved mode.
+ * @module @deepseek-ai/dsh/args
+ */
+
+import { Command, CommanderError } from 'commander'
+
+/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */
+interface TuiInvocation {
+ mode: 'tui'
+ config?: string
+ resume?: string
+}
+
+/** Headless one-shot: `dsh -p "task"`. */
+interface HeadlessInvocation {
+ mode: 'headless'
+ prompt: string
+}
+
+/**
+ * Browser UI: `dsh web`. `host`/`port` are present only when the flag was
+ * passed — pass-through overrides with no CLI default and no CLI validation:
+ * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
+ * `port` a natural ≤ 65535) is the single source of both the default (the
+ * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
+ * value fails loud at boot). `port` is `Number`-coerced only because the schema
+ * wants a number, not a string. `dev` mounts the client HMR driver;
+ * `workspaceRoot` is the parent directory for name-created workspaces.
+ */
+interface WebInvocation {
+ mode: 'web'
+ host?: string
+ port?: number
+ dev: boolean
+ workspaceRoot?: string
+}
+
+/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
+export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
+
+/** Raw web-subcommand options straight from Commander. */
+interface WebOptions {
+ host?: string
+ port?: string
+ dev?: boolean
+ workspaceRoot?: string
+}
+
+/**
+ * Narrow the raw `web` options into a {@link WebInvocation}. No host/port
+ * validation: both flow to the webserver schema, which is the sole gate. `port`
+ * is coerced to a number (the schema rejects a string) but not range-checked
+ * here — `NaN`/out-of-range fail loud at the schema on boot.
+ */
+function resolveWeb(options: WebOptions): WebInvocation {
+ return {
+ mode: 'web',
+ ...options.host !== undefined && { host: options.host },
+ ...options.port !== undefined && { port: Number(options.port) },
+ dev: options.dev === true,
+ ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
+ }
+}
+
+/**
+ * Resolve the raw argv into a {@link DshInvocation}, or print and exit for
+ * `--help`/`--version`/a parse error. The default (no subcommand) is the
+ * TUI/headless surface; `web` is a subcommand.
+ * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
+ * @param version - the version string `--version` prints; read from this app's package.json.
+ * @returns the resolved invocation (only reached on a valid, non-help invocation).
+ */
+export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
+ let resolved: DshInvocation | undefined
+ const program = new Command()
+ .name('dsh')
+ .version(version, '-V, --version', 'output the version number')
+ .description('dsh: interactive TUI (default), headless task, and browser UI')
+ .exitOverride()
+ // Default surface: option-only (no positional), so `web` can be a real
+ // subcommand without a positional collision.
+ .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
+ .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit')
+ .option('--resume ', 'resume the persisted session with this id (TUI mode)')
+ .action((options: { config?: string; prompt?: string; resume?: string }) => {
+ if (options.prompt !== undefined) {
+ // A headless prompt owns the invocation; an empty task has nothing to
+ // run, and --config/--resume are TUI inputs that must not silently
+ // vanish from a headless run.
+ if (options.prompt === '') program.error('error: --prompt needs a task')
+ if (options.config !== undefined || options.resume !== undefined) {
+ program.error('error: --prompt takes no --config or --resume')
+ }
+ resolved = { mode: 'headless', prompt: options.prompt }
+ return
+ }
+ // An empty --resume= id would silently start a fresh session downstream
+ // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
+ if (options.resume === '') program.error('error: --resume needs a session id')
+ resolved = {
+ mode: 'tui',
+ ...options.config !== undefined && { config: options.config },
+ ...options.resume !== undefined && { resume: options.resume },
+ }
+ })
+
+ const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
+ web
+ .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
+ .option('--port ', 'override the config listen port (0 requests an OS-assigned port)')
+ .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
+ .option('--workspace-root ', 'parent directory for name-created workspaces')
+ .action((options: WebOptions) => {
+ // Commander parses the parent (default-surface) options on either side of
+ // the subcommand into `program.opts()`. `web` shares none of them, so a
+ // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
+ // fail loud rather than silently start the web server and drop it.
+ const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
+ if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
+ program.error('error: web takes none of --config, -p/--prompt, or --resume')
+ }
+ resolved = resolveWeb(options)
+ })
+
+ try {
+ program.parse(argv, { from: 'user' })
+ } catch (error) {
+ // Commander printed help/version/the error under `exitOverride`; exit with
+ // the code it chose (0 for help/version, 1 for a parse or domain error).
+ /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
+ return process.exit(error instanceof CommanderError ? error.exitCode : 1)
+ }
+ /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
+ if (resolved === undefined) throw new Error('dsh: no invocation resolved')
+ return resolved
+}
diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts
index 1192472b98..f9e1eefc9b 100644
--- a/apps/cli/src/bin.ts
+++ b/apps/cli/src/bin.ts
@@ -1,25 +1,49 @@
#!/usr/bin/env node
/**
- * dsh — command-line entry. Coarse dispatch only; each surface module owns its
- * argument handling. Dynamic imports keep unrelated surfaces out of each
- * dispatch path; everything except `web` and headless prompts opens the TUI.
+ * dsh — command-line entry. Dynamic imports per mode keep unrelated modes out
+ * of each dispatch path; the adapter prints and exits for
+ * `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* @module @deepseek-ai/dsh/bin
*/
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
+import { parseDshArgs } from './args.ts'
+
+// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
+// one directory under apps/cli, so the checked-in manifest resolves with the
+// same relative hop from either artifact.
+/** This app's version, read from its checked-in package.json. */
+function readVersion(): string {
+ const manifest = JSON.parse(
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
+ ) as { version?: unknown }
+ return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
+}
loadEnv('dsh')
-const argv = process.argv.slice(2)
+const invocation = parseDshArgs(process.argv.slice(2), readVersion())
-if (argv[0] === 'web') {
- const { runWeb } = await import('./web.ts')
- await runWeb(argv.slice(1))
-} else if (argv.includes('-p') || argv.includes('--prompt')) {
- const { runHeadless } = await import('./headless.ts')
- await runHeadless(argv)
-} else {
- const { runTui } = await import('./tui.ts')
- await runTui(argv)
+switch (invocation.mode) {
+ case 'web': {
+ const { runWeb } = await import('./web.ts')
+ await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
+ break
+ }
+ case 'headless': {
+ const { runHeadless } = await import('./headless.ts')
+ await runHeadless(invocation.prompt)
+ break
+ }
+ case 'tui': {
+ const { runTui } = await import('./tui.ts')
+ await runTui(invocation.config, invocation.resume)
+ break
+ }
+ default:
+ invocation satisfies never
+ throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}
diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts
index 303bac61f8..111a41d4e3 100644
--- a/apps/cli/src/headless.ts
+++ b/apps/cli/src/headless.ts
@@ -1,18 +1,19 @@
/**
- * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
- * injection (InProcessApiClient over the host handler, so the full carrier
- * chain — wire serialization, zod, SSE framing — really runs; this is the
- * protocol's second real consumer). No HTTP server, no port, no dist
- * resolution. Runs one task turn, prints the final assistant text, exits
- * (completed → 0, else 1).
+ * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
+ * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
+ * collide), then in-process isomorphic injection (InProcessApiClient over
+ * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
+ * serialization, zod, SSE framing — really runs). The printed URL opens the
+ * live session in a browser while the task runs. Runs one task turn, prints
+ * the final assistant text, exits (completed → 0, else 1).
*/
-import { parseArgs } from 'node:util'
-import { startHost } from '@deepseek-ai/dsh-host-runtime'
-import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
+import { fileURLToPath } from 'node:url'
+import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
+import { AppCLIEntry } from './app-cli-entry.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -65,28 +66,26 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>,
return { text, reason: 'error' }
}
-export async function runHeadless(argv: string[]): Promise {
- const { values } = parseArgs({
- args: argv,
- options: { prompt: { type: 'string', short: 'p' } },
- allowPositionals: false,
- })
- const task = values.prompt
- if (task === undefined || task === '') {
- process.stderr.write('usage: dsh -p "task"\n')
- process.exit(1)
- }
-
+/**
+ * Run one headless turn for `task` and exit (completed → 0, else 1). The task
+ * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
+ * (the adapter rejects an empty task, so no guard is needed here).
+ * @param task - the prompt text for the single turn.
+ */
+export async function runHeadless(task: string): Promise {
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
- const host = await startHost({
- boot: {
- persistenceRoot: './.sessions',
- workspaceContext: false,
- },
+ const entry = new AppCLIEntry({
+ configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
+ dev: false,
+ port: 0,
})
- const api = new InProcessApiClient(host.handler)
+ const { ctx, port } = await entry.run()
+ const dispose = async (): Promise => { await ctx.fiber.dispose() }
+ // The headless session is web-observable while it runs (same composition).
+ process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
+ const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
- const created = await unwrap(await api.sessions.create({}), () => host.dispose())
+ const created = await unwrap(await api.sessions.create({}), dispose)
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -99,11 +98,11 @@ export async function runHeadless(argv: string[]): Promise {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
- }), () => host.dispose())
+ }), dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
- await host.dispose()
+ await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index 4a4ca8d7fc..4283668189 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -1,6 +1,6 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
- * tui-agent config (or an explicit config argument) with the personal overlay
+ * tui-agent config (or the `--config` override) with the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
@@ -18,8 +18,7 @@ import {
installFailLoud,
loadEnv,
loadPersonalPatches,
- parseResumeArg,
- replaceResumeArg,
+ RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -28,12 +27,6 @@ import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
const NAME = 'dsh'
-// The env var the shipped tui-agent config reads (`resumeSessionId: !!js
-// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The
-// `--resume ` flag is CLI sugar that sets it before boot, so the printed
-// `dsh --resume ` exit hint runs back through this same intake.
-const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID'
-
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the shipped default config resolves with
// the same relative hop from either artifact.
@@ -48,26 +41,30 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
the tui-agent PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI from the invoking directory.
- * @param argv - arguments after the subcommand dispatch; a `--resume ` flag
- * resumes that persisted session, and the first non-flag argument may name a
- * config to boot instead of the shipped default.
+ * @param config - a config path to boot instead of the shipped default, or
+ * `undefined` for the default; already parsed from `--config`.
+ * @param resumeSessionId - a persisted session id to resume, or `undefined`;
+ * already parsed and non-empty-validated from `--resume`. It is provided on the
+ * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
+ * reads through `!!js` to rehydrate that session.
*/
-export async function runTui(argv: string[]): Promise {
+export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
- process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`)
+ process.stderr.write(
+ `${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`,
+ )
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
- // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it
- // after loadEnv and before boot reads it through the config's `!!js`.
- const { resumeSessionId, rest } = parseResumeArg(argv)
- if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
+ // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
+ // flag, so the resumed process rehydrates through this same intake. The host
+ // is offered only when Node exposes `process.execve` and knows its own entry.
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
@@ -75,13 +72,15 @@ export async function runTui(argv: string[]): Promise {
async handoff(sessionId): Promise {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
+ // Rebuild argv from the parsed config plus the selected id: TUI mode's
+ // only arguments are `--config ` and `--resume `.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
- ...replaceResumeArg(process.argv.slice(2), sessionId),
+ `--resume=${sessionId}`,
+ ...config !== undefined ? ['--config', config] : [],
]
- process.env[RESUME_SESSION_ID_ENV] = sessionId
try {
await current.fiber.dispose()
execve(process.execPath, nextArgv, process.env)
@@ -94,9 +93,12 @@ export async function runTui(argv: string[]): Promise {
}
const ctx = await boot(
NAME,
- resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined),
+ resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
(hostCtx) => {
+ // Inject the resume id (or undefined) so the shipped config's `!!js`
+ // reads it as a bare identifier; then offer the in-place handoff host.
+ hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
},
)
diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts
index bcd1482df3..31282c8f5f 100644
--- a/apps/cli/src/web.ts
+++ b/apps/cli/src/web.ts
@@ -1,49 +1,43 @@
/**
- * `dsh web` — thin bin over the config-tree boot: parse argv, run
- * AppCLIEntry, print the URL line, wire signals. All composition lives in
- * cordis.yml; all boot glue lives in AppCLIEntry.
+ * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the
+ * already-parsed host/port/dev, print the URL line, wire signals. All
+ * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and
+ * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
+ * gates them at boot.
*/
-import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
+const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
+
+// Display-only mirrors of the webserver schema's allowed hosts: the loopback
+// address the local URL always prints, and the all-interfaces value that gates
+// LAN-address discovery. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
-const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-
-export async function runWeb(argv: string[]): Promise {
- const { values } = parseArgs({
- args: argv,
- options: {
- host: { type: 'string' },
- port: { type: 'string' },
- dev: { type: 'boolean', default: false },
- },
- allowPositionals: false,
- })
- if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
- process.stderr.write(
- `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
- )
- process.exit(1)
- }
- let port: number | undefined
- if (values.port !== undefined) {
- port = Number(values.port)
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
- process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
- process.exit(1)
- }
- }
-
+/**
+ * Serve the browser UI from the shipped config tree. `host`/`port` are passed
+ * through only when the flag was given; absent, the `cordis.yml` value stands.
+ * @param host - the bind host, or `undefined` to keep the config default.
+ * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
+ * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
+ * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
+ */
+export async function runWeb(
+ host: string | undefined,
+ port: number | undefined,
+ dev: boolean,
+ workspaceRoot: string | undefined,
+): Promise {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
- dev: values.dev,
- ...values.host !== undefined ? { host: values.host } : {},
- ...port !== undefined ? { port } : {},
+ dev,
+ ...host !== undefined && { host },
+ ...port !== undefined && { port },
+ ...workspaceRoot !== undefined && { workspaceRoot },
})
const { ctx, port: boundPort } = await entry.run()
@@ -54,7 +48,7 @@ export async function runWeb(argv: string[]): Promise {
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
- const lanCandidate = values.host === ALL_INTERFACES_HOST
+ const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts
new file mode 100644
index 0000000000..052e96e9a0
--- /dev/null
+++ b/apps/cli/tests/args.spec.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { parseDshArgs } from '../src/args.ts'
+
+const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
+
+/**
+ * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
+ * Commander print to the real streams; capture the exit code and mute output.
+ */
+function exitCode(argv: string[]): number {
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true)
+ vi.spyOn(process.stderr, 'write').mockReturnValue(true)
+ try {
+ parse(argv)
+ throw new Error(`expected ${JSON.stringify(argv)} to exit`)
+ } catch {
+ return exit.mock.calls.at(-1)?.[0] as number
+ } finally {
+ vi.restoreAllMocks()
+ }
+}
+
+afterEach(() => { vi.restoreAllMocks() })
+
+describe('parseDshArgs', () => {
+ it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
+ expect(parse([])).toEqual({ mode: 'tui' })
+ expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
+ 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' })
+ // Bare `web` carries no host/port: the shipped cordis.yml owns the default.
+ expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
+ // 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']))
+ .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
+ })
+
+ it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
+ // Empty resume/prompt would be swallowed downstream; --prompt mixed with
+ // TUI inputs must not lose them. (Bad host/port are gated by the webserver
+ // schema at boot, not here.)
+ expect(exitCode(['--resume='])).toBe(1)
+ expect(exitCode(['-p', ''])).toBe(1)
+ expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
+ expect(exitCode(['--bogus'])).toBe(1)
+ expect(exitCode(['bogus-positional'])).toBe(1)
+ // A default-surface flag on either side of `web` leaks into program.opts()
+ // but the web subcommand shares none of them: reject rather than serve.
+ expect(exitCode(['web', '-p', 'task'])).toBe(1)
+ expect(exitCode(['web', '--resume', 's'])).toBe(1)
+ expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
+ })
+
+ it('exits 0 for --help (disclosing web) and --version', () => {
+ expect(exitCode(['--help'])).toBe(0)
+ expect(exitCode(['--version'])).toBe(0)
+ })
+})
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
new file mode 100644
index 0000000000..9fd1d55ab2
--- /dev/null
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -0,0 +1,55 @@
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
+ * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
+ * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
+ * piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
+ * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
+ * tree — a compose-time throw inside the tree is logged per-entry, not
+ * rethrown, so without this guard a piped launch would settle into an idle
+ * UI-less process. The bin resolves its workspace deps through the repo's
+ * node_modules, so no external consumer is assembled; missing-config fail-loud
+ * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
+ * built-bin suite, and interactive TTY behavior is PTY-covered by
+ * examples/tui-agent. Skips before the bin is built.
+ */
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
+
+/** Run the built bin with PIPED stdio; resolve with output + exit code. */
+function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
+ let stdout = ''
+ let stderr = ''
+ child.stdout.setEncoding('utf8')
+ child.stdout.on('data', (c: string) => { stdout += c })
+ child.stderr.setEncoding('utf8')
+ child.stderr.on('data', (c: string) => { stderr += c })
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL')
+ reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
+ }, 25_000)
+ // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
+ child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
+ child.on('error', (err) => { clearTimeout(timer); reject(err) })
+ child.stdin.end()
+ })
+}
+
+describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
+ it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
+ const { stdout, code, stderr } = await runBuiltBin()
+ expect(code).not.toBe(0)
+ expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
+ expect(stderr).toContain('dsh -p')
+ // The refusal happens before any plugin mounts: stdout stays silent.
+ expect(stdout).toBe('')
+ }, 30_000)
+})
diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json
index b33280943a..05947889b2 100644
--- a/apps/cli/tsconfig.json
+++ b/apps/cli/tsconfig.json
@@ -14,9 +14,6 @@
{
"path": "../../packages/host/apiproxy"
},
- {
- "path": "../../packages/host/runtime"
- },
{
"path": "../../packages/host/webserver"
},
@@ -45,7 +42,16 @@
"path": "../../packages/client/ui-theme"
},
{
- "path": "../../packages/client/i18n"
+ "path": "../../packages/client/ui-settings"
+ },
+ {
+ "path": "../../packages/client/ui-settings-general"
+ },
+ {
+ "path": "../../packages/client/ui-models"
+ },
+ {
+ "path": "../../packages/client/locale"
},
{
"path": "../../packages/client/ui-layout"
diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts
index c64f307f7c..0a9b04ea5f 100644
--- a/apps/web/src/node-module-stub.ts
+++ b/apps/web/src/node-module-stub.ts
@@ -1,10 +1,6 @@
/**
- * Browser stand-in for `node:module`, mapped by the vite alias in
- * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
- * `createRequire` at module scope but only calls it inside
- * `ModuleLoader.fromInternal()`, whose version probe is compiled to the
- * `"0.0.0"` define in the browser build — so this throw is a fail-loud
- * tripwire for any path that would genuinely need Node's module machinery.
+ * Browser stand-in for `node:module`. `createRequire` is unreachable in the
+ * configured loader path and fails loud if that assumption changes.
*/
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts
new file mode 100644
index 0000000000..131f3fa5fd
--- /dev/null
+++ b/apps/web/tests/replay-round-trip.e2e.ts
@@ -0,0 +1,129 @@
+// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
+// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
+// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
+// or the live adapter (record). Drive steps run in every mode and wait only
+// on generic completion (whenTurnSettled — never model-content selectors, so
+// record cannot hang on a live model answering differently); assertion steps
+// run in replay/refresh only. Settled states only — streaming incrementality
+// is asserted from the persisted assistant/chunk events, not transient DOM.
+// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
+// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+
+// The scenario's one drive prompt. Record sends it; replay asserts the
+// committed fixture recorded exactly it, so drive script and fixture cannot
+// drift apart.
+const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.'
+
+describe('web e2e: fresh round trip through the real assembly', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const sessionEvents: SessionEvent[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({
+ ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
+ })
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('drives the recorded prompt to a settled turn (all modes)', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip'))
+ if (MODE !== 'record') {
+ // Drift guard: the committed fixture must carry exactly the drive prompt.
+ expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+ }
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ // Arm the host-side settled barrier BEFORE the send click.
+ const settled = scaffold.whenTurnSettled()
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ const sessionId = await settled
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ }
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
+ // Browser settled-poll after host completion (host strictly precedes render).
+ await page.locator('[data-streaming="true"]').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {
+ // Chunks may coalesce into one commit; a never-mounted streaming node is
+ // legal — the chunk-event assertions below carry incrementality.
+ })
+ await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+ // World state, not self-report: the real bash executor returned the exact
+ // command output, and the turn closed cleanly.
+ const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash')
+ if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool')
+ const bashResult = sessionEvents.find(event =>
+ event.type === 'tool/result' && event.data.callId === bashCall.data.callId)
+ if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
+ expect(bashResult.data.isError).toBe(false)
+ expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join(''))
+ .toBe('WEB_E2E_OK\n')
+ const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
+ expect(turnEnds.length).toBe(1)
+ expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+ // The persisted chunk events are the authoritative incrementality proof.
+ expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-aria'))
+ // Anchor assertions survive a semantics-preserving component rewrite even
+ // while the whole-region golden churns.
+ await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
+ expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
+ const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ })
+
+ it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
+ // Interaction over the REAL wire-delivered transcript (the fixture-client
+ // tier pins the same gesture against FixtureApiClient; this one runs on
+ // mux-frame-fed state). Runs after the golden capture so the committed
+ // aria surface stays the untouched settled state.
+ const think = page.getByRole('button', { name: /^Think/ }).first()
+ expect(await think.getAttribute('aria-expanded')).toBe('false')
+ await think.click()
+ await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
+ await think.click()
+ await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+ })
+
+ it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
new file mode 100644
index 0000000000..babfbde919
--- /dev/null
+++ b/apps/web/tests/scaffold.ts
@@ -0,0 +1,440 @@
+// Shared scaffold for the keyless browser e2e lane (Agent Note:
+// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
+// Boots the REAL web composition — the shipped apps/cli/cordis.yml through
+// the vendored Loader (the same include boot AppCLIEntry drives), patched the
+// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
+// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
+// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
+// inserted in providers mode), record (real adapter + key, harvests fixtures
+// from live session memory), refresh (keyless replay that rewrites goldens).
+//
+// Composition divergences from `dsh web`, all deliberate, all via include
+// patches over the SAME tree (never a second yml): temp persistenceRoot;
+// workspace-context disabled (recorded fixtures must not embed this repo's
+// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
+// would race the loop for the session's replay cursor); webserver pinned to
+// port 0 with the built dist; keyless modes disable llm-deepseek and fill
+// the open llm seam post-boot with installLlmReplay on the settled root ctx
+// (the plugin-row path discards the ReplayHandle; the direct install keeps
+// assertConsumed for the teardown fixture-consumption check).
+import { existsSync, readFileSync } from 'node:fs'
+import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import type { Page } from 'playwright'
+import { expect } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include, { type PatchOptions } from '@cordisjs/plugin-include'
+import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
+import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
+import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
+import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import type {} from '@deepseek-ai/dsh-agent'
+import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
+
+/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
+export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
+
+/**
+ * Resolve and validate the lane's snapshot mode.
+ * @returns the active mode; unset/empty selects replay.
+ */
+export function webSnapshotMode(): WebSnapshotMode {
+ const value = process.env.DSH_SNAPSHOT
+ if (value === undefined || value === '' || value === 'replay') return 'replay'
+ if (value === 'record' || value === 'refresh') return value
+ throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
+}
+
+/** The shipped composition under test: apps/cli's config tree. */
+const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
+
+// Replay publishes the provider catalog the gateway routes to (providers
+// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
+// catch-all would leave resolveModelContext unroutable and compact-basic's
+// post-step pressure check would warn every step). The published
+// contextWindow keeps that pressure path provably inert for small fixtures.
+const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
+
+/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
+function loadRootEnv(): void {
+ const envPath = join(REPO_ROOT, '.env')
+ if (!existsSync(envPath)) return
+ for (const line of readFileSync(envPath, 'utf8').split('\n')) {
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
+ if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
+ }
+}
+
+/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
+export interface WebScaffold {
+ /** The active snapshot mode this scaffold booted under. */
+ mode: WebSnapshotMode
+ /** Browser-facing origin (http://127.0.0.1:). */
+ baseUrl: string
+ /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
+ ctx: Context
+ /** Temp project directory sessions run in (bash/fs tool cwd). */
+ workspaceCwd: string
+ /** Temp persistence root (seeded sessions land here through the real API). */
+ persistenceRoot: string
+ /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
+ whenTurnSettled(timeoutMs?: number): Promise
+ /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
+ close(): Promise
+}
+
+/** Options for {@link launchWebScaffold}. */
+export interface LaunchOptions {
+ /**
+ * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
+ * in replay/refresh modes; ignored in record mode (the real adapter
+ * answers). Omit for scenarios issuing no model calls — a stray stream then
+ * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
+ * mounts).
+ */
+ replayFixture?: string
+ /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
+ paceMs?: number
+}
+
+/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
+async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise {
+ const failures: unknown[] = []
+ await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
+ await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+ await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+ return failures
+}
+
+/**
+ * Boot the real web composition under the current snapshot mode.
+ * @param options - replay fixture selection and pacing.
+ * @returns the running scaffold.
+ */
+export async function launchWebScaffold(options: LaunchOptions = {}): Promise {
+ requireDist()
+ const mode = webSnapshotMode()
+ if (mode === 'record') {
+ loadRootEnv()
+ if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
+ throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
+ }
+ }
+ const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-'))
+ let persistenceRoot: string
+ try {
+ persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
+ } catch (error) {
+ const failures: unknown[] = [error]
+ await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
+ if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
+ throw error
+ }
+
+ // The include patch set — the same mechanism AppCLIEntry and the ACP
+ // snapshot overlay use, applied over the SAME shipped tree (a patch id that
+ // stops matching a row fails the boot sweep loudly instead of drifting).
+ const patches: PatchOptions[] = [
+ { id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
+ // storage-json's './.storages' yml default is cwd-relative and resolves
+ // per write; the scaffold restores the original cwd after boot, so the
+ // row gets an absolute temp root (removed with the workspace at close).
+ { id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
+ // fs/bash cwd default to process.cwd(); the gateway injects the same
+ // value into session.cwd — chdir below anchors all three to the temp
+ // workspace, keeping the composition untouched.
+ { id: 'workspace-context', disabled: true },
+ { id: 'session-title-llm', disabled: true },
+ { id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
+ ...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
+ ]
+
+ // Sessions inherit the gateway's process.cwd() default; run the boot from
+ // the temp workspace so tool cwd, session cwd, and fixtures agree.
+ const originalCwd = process.cwd()
+ const ctx = new Context()
+ let port = 0
+ let replayHandle: ReplayHandle | undefined
+ try {
+ process.chdir(workspaceCwd)
+ ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ await ctx.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
+ })
+ await ctx.loader.await()
+ assertEntriesLoaded(ctx, 'web e2e scaffold')
+ const boundPort = ctx.get('httpServer')?.port
+ if (boundPort === undefined) {
+ throw new Error('web e2e scaffold: httpServer service missing after settled boot')
+ }
+ port = boundPort
+
+ // Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
+ // in keyless modes; a scenario with no fixture leaves the seam empty so a
+ // stray stream fails loud with NO_ADAPTER). The direct install, unlike the
+ // plugin row, returns the ReplayHandle for the teardown consumption check.
+ if (mode !== 'record' && options.replayFixture !== undefined) {
+ replayHandle = installLlmReplay(ctx, {
+ file: options.replayFixture,
+ providers: REPLAY_PROVIDERS,
+ ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
+ })
+ }
+ } catch (error) {
+ if (process.cwd() !== originalCwd) process.chdir(originalCwd)
+ const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
+ if (cleanupFailures.length > 0) {
+ throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
+ }
+ throw error
+ } finally {
+ if (process.cwd() !== originalCwd) process.chdir(originalCwd)
+ }
+
+ return {
+ mode,
+ baseUrl: `http://127.0.0.1:${port}`,
+ ctx,
+ workspaceCwd,
+ persistenceRoot,
+ // Barrier stack: the in-process turn/end identifies the session, then
+ // agent.whenIdle() covers the persistence flush (the idle flip follows
+ // the flush), and the caller's browser settled-poll comes last because
+ // host completion strictly precedes render.
+ whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise {
+ return new Promise((resolveSettled, reject) => {
+ const timer = setTimeout(() => {
+ off()
+ reject(new Error(`no turn/end within ${timeoutMs}ms`))
+ }, timeoutMs)
+ const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
+ if (event.type !== 'turn/end') return
+ clearTimeout(timer)
+ off()
+ const agent = ctx.agents.get(session.id)
+ if (agent === undefined) {
+ reject(new Error(`turn/end for ${session.id} but no live agent`))
+ return
+ }
+ agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
+ })
+ })
+ },
+ async close(): Promise {
+ const failures: unknown[] = []
+ // Fixture-consumption check first, while the run's binding state is
+ // still authoritative — a scenario that drove fewer model calls than
+ // recorded fails here instead of drifting green.
+ try {
+ replayHandle?.assertConsumed()
+ } catch (error) {
+ failures.push(error)
+ }
+ failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
+ if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
+ },
+ }
+}
+
+/**
+ * Serialize a live session back to raw session-JSONL (header + events) — the
+ * in-memory record-mode harvest, so the on-disk zstd default never matters.
+ * Mirrors the TUI suite's rawSessionLog.
+ */
+function rawSessionLog(session: Session): string {
+ return [
+ JSON.stringify({ type: 'session', ...session.header }),
+ ...session.events.map(event => JSON.stringify(event)),
+ '',
+ ].join('\n')
+}
+
+/**
+ * Record-mode fixture write-back: harvest the live session, scrub request
+ * headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no
+ * header class — a deliberate deviation logged in the Agent Note's deferred
+ * work), tokenize the run-local session id, cwd, and browser RPC id
+ * ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention —
+ * re-records then diff only on real content), and write the fixture.
+ * @param scaffold - the record-mode scaffold.
+ * @param sessionId - the driven session.
+ * @param fixturePath - the committed session.jsonl / seed.jsonl target.
+ */
+export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise {
+ const agent = scaffold.ctx.agents.get(sessionId)
+ if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
+ const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
+ .split(sessionId).join('{{sessionId}}')
+ .split(scaffold.workspaceCwd).join('{{cwd}}')
+ .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
+ await writeFile(fixturePath, tokenized)
+}
+
+/**
+ * The user prompts recorded in a fixture, in order — the single source tying
+ * spec drive steps to recorded reality so script and fixture cannot drift.
+ * @param fixtureText - raw session.jsonl contents.
+ * @returns the recorded user prompt texts.
+ */
+export function fixtureUserPrompts(fixtureText: string): string[] {
+ return parseSessionLog(fixtureText).flatMap((event) => {
+ if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
+ const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
+ return text.length > 0 ? [text] : []
+ })
+}
+
+/**
+ * Seed a recorded session fixture into the scaffold's persistence root
+ * through the REAL backend API (throwaway Context + SessionStore + JSONL
+ * plugin — the semantic-checkpoint precedent), never raw file writes: no
+ * knowledge of bucket hashing, filename encoding, or compression, and
+ * malformed shapes fail loud at seed time. The fixture's tokenized identity
+ * ({{sessionId}}/{{cwd}}) is realized for this world before parsing.
+ * @param scaffold - the target scaffold.
+ * @param fixtureText - raw recorded session.jsonl contents.
+ * @param id - the seeded session id (stable for deterministic goldens).
+ * @returns the seeded id.
+ */
+export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise {
+ const realized = fixtureText
+ .split('{{sessionId}}').join(id)
+ .split('{{cwd}}').join(scaffold.workspaceCwd)
+ const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
+ const rewritten = fixtureCwd === undefined
+ ? realized
+ : realized.split(fixtureCwd).join(scaffold.workspaceCwd)
+ const events = parseSessionLog(rewritten)
+ if (events.length === 0) throw new Error('seed fixture has no events')
+ const last = events[events.length - 1]!
+ // An open final turn would be mutated by resume's crash repair on first
+ // open; a committed seed must be a closed recording.
+ if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`)
+ const meta: SessionHeader = {
+ version: SESSION_FORMAT_VERSION,
+ id: SessionId(id),
+ createdAt: Date.now() - 60_000,
+ cwd: scaffold.workspaceCwd,
+ delegationDepth: 0,
+ }
+ const seeder = new Context()
+ try {
+ await seeder.plugin(SessionStore)
+ // Same root as the booted tree with the plugin's own default compression,
+ // so the host's directory-scan list() sees one consistent encoding.
+ await seeder.plugin(SessionPersistenceJsonl, { root: scaffold.persistenceRoot })
+ await seeder.sessionPersistence.create(meta)
+ await seeder.sessionPersistence.append(meta.id, events)
+ // Deterministic sidebar order: cold summaries take updatedAt from mtime.
+ const located = seeder.sessionPersistence.locate(meta)
+ if (located !== undefined) {
+ const backdated = new Date(meta.createdAt)
+ await utimes(located.path, backdated, backdated)
+ }
+ } finally {
+ await seeder.fiber.dispose()
+ }
+ return meta.id
+}
+
+/**
+ * Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
+ * volatility collapse to stable tokens.
+ */
+function normalizeAria(snapshot: string, workspaceCwd: string): string {
+ // The header breadcrumb renders the workspace's basename, not the full
+ // path, so both spellings must collapse to the token.
+ const base = workspaceCwd.split('/').pop()!
+ return snapshot
+ .split(workspaceCwd).join('{{cwd}}')
+ .split(base).join('{{workspace}}')
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
+ .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
+}
+
+/**
+ * Capture the region's aria snapshot at a settled milestone: poll until two
+ * consecutive normalized captures are equal — a single-shot capture races the
+ * last React commits.
+ * @param page - the page under test.
+ * @param selector - the region locator selector.
+ * @param workspaceCwd - normalization input.
+ * @returns the stable normalized snapshot.
+ */
+export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise {
+ const region = page.locator(selector).first()
+ let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
+ await expect.poll(async () => {
+ const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
+ const stable = current === previous
+ previous = current
+ return stable
+ }, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true)
+ return previous
+}
+
+/**
+ * Compare a normalized golden, or rewrite it under refresh. Refresh is the
+ * ONLY writer: a missing golden in replay mode fails with the healing command
+ * instead of silently self-bootstrapping.
+ * @param goldenPath - the committed ui.expected.md path.
+ * @param actual - the stable normalized snapshot.
+ * @param mode - the active snapshot mode.
+ */
+export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise {
+ const payload = `${actual}\n`
+ if (mode === 'refresh') {
+ await writeFile(goldenPath, payload)
+ return
+ }
+ if (!existsSync(goldenPath)) {
+ throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`)
+ }
+ expect(payload).toBe(await readFile(goldenPath, 'utf8'))
+}
+
+/**
+ * Fixture-inventory guard (the TUI afterAll shape): the scenario directory
+ * holds exactly the expected files and every committed JSONL is a scrub
+ * fixed-point without a run-local browser RPC id.
+ * @param dir - the scenario snapshot directory.
+ * @param expected - the exact expected file inventory.
+ */
+export async function assertFixtureInventory(dir: string, expected: string[]): Promise {
+ const entries = (await readdir(dir)).sort()
+ expect(entries).toEqual([...expected].sort())
+ for (const entry of entries.filter(name => name.endsWith('.jsonl'))) {
+ const content = await readFile(join(dir, entry), 'utf8')
+ expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content)
+ expect(content, `${dir}/${entry} carries a run-local rpcId`)
+ .not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/)
+ }
+}
+
+/**
+ * Console tripwires: reconnect/gap-repair self-healing or a pageerror must
+ * fail the scenario, not mask a dead wire behind eventual consistency.
+ * @param page - the page under test.
+ * @returns live warning/pageerror collectors to assert empty at scenario end.
+ */
+export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } {
+ const warnings: string[] = []
+ const pageErrors: string[] = []
+ page.on('console', (message) => {
+ const text = message.text()
+ if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text)
+ })
+ page.on('pageerror', (error) => { pageErrors.push(String(error)) })
+ return { warnings, pageErrors }
+}
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
new file mode 100644
index 0000000000..97ebfff0b1
--- /dev/null
+++ b/apps/web/tests/seeded-history.e2e.ts
@@ -0,0 +1,124 @@
+// Web e2e scenario: seeded history. A recorded session seeded cold through
+// the REAL persistence API renders purely from the log — the surface nothing
+// else covers: sidebar cold listing, the implicit resume/attach inside the
+// history RPC, history-page tool views, and the client fold of historical
+// events — with ZERO model calls in replay (no replay fixture; a stray stream
+// fails loud on the open llm seam). The seed is a recorded fixture under the
+// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
+// live through the composer (real read tool against seeded workspace files)
+// and harvests seed.jsonl; replay/refresh seed it cold and only render.
+import { readFile, writeFile, mkdir } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { join } from 'node:path'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
+const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'seeded-history-web-e2e'
+
+const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
+
+describe('web e2e: seeded history renders through cold resume', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ // The workspace-aware flow runs sessions in /workspace
+ // (the composer's default draft name); the read-tool targets must live in
+ // that session cwd. Pre-creating the directory is safe: create-by-name
+ // adopts an existing directory.
+ const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
+ await mkdir(sessionCwd, { recursive: true })
+ await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
+ await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
+ if (MODE !== 'record') {
+ const raw = await readFile(SEED, 'utf8')
+ expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
+ await seedSession(scaffold, raw, SEED_ID)
+ }
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record'))
+ const input = page.locator('textarea').first()
+ await input.waitFor({ timeout: 10_000 })
+ const settled = scaffold.whenTurnSettled()
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ const sessionId = await settled
+ await recordFixture(scaffold, sessionId, SEED)
+ }, 200_000)
+
+ it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
+ // The sidebar tree collapses workspace groups by default: click the group
+ // row (treeitem 0) to expand, then the revealed session row.
+ const groupRow = page.locator('[role="treeitem"]').first()
+ await groupRow.waitFor({ timeout: 15_000 })
+ await groupRow.click()
+ const sessionRow = page.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 10_000 })
+ await sessionRow.click()
+ // Settled barrier for history: the recorded final assistant text renders.
+ await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+ // Tool cards render from logged tool/call + tool/result alone (views are
+ // host-recomputed per page; the generic card is the documented default).
+ const toolRows = page.locator('[data-variant], [data-sample]')
+ await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
+ expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
+ }, 60_000)
+
+ it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
+ const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+ })
+
+ it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
+ // Interaction over cold-resumed history: read rows are expand-in-place
+ // rows (rowExpands routes the click to toggleExpand, not openDetails), so
+ // the gesture under test is the inline fold over log-rebuilt content.
+ // Runs after the golden capture; still zero model calls.
+ const row = page.locator('[data-variant] [data-clickable][role="button"]').first()
+ await row.waitFor({ timeout: 10_000 })
+ expect(await row.getAttribute('aria-expanded')).toBe('false')
+ await row.click()
+ await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
+ // The expanded body renders the recorded tool result (a.txt's contents).
+ await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
+ await row.click()
+ await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+ })
+
+ it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
+ // No replay fixture was installed and the llm seam is open — any stray
+ // stream would have failed the turn loudly. Cleanliness pins the wire.
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts
index 673e92f9ce..313573ea94 100644
--- a/apps/web/tests/session-title.snapshot.ts
+++ b/apps/web/tests/session-title.snapshot.ts
@@ -10,10 +10,14 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
- { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
+ { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -72,12 +76,12 @@ afterEach(() => {
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
const tree = screen.getByRole('tree', { name: 'Sessions' })
const sidebar = within(tree).getByText(label).textContent ?? ''
- const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' }))
+ const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
.getByRole('button', { name: label }).textContent ?? ''
return { sidebar, breadcrumb, documentTitle: document.title }
}
-it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
+it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
const root = document.querySelector('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {
@@ -92,10 +96,11 @@ it('projects initial and revised durable titles through the built eight-plugin f
unmount = () => { entry.dispose() }
})
- const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })
- const projectRow = projectLabel.closest('[role="treeitem"]')
- if (projectRow === null) throw new Error('fixture project row missing')
- fireEvent.click(projectRow)
+ const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ // The fixture Intent selects the workspace, so the current-group effect
+ // already expanded it; clicking the header would now collapse (the twist
+ // stays live since intent stopped forcing expansion).
+ await within(tree).findByText('4 sessions')
const initialLabel = 'Fixture 历史会话'
const initialRowLabel = await screen.findByText(initialLabel)
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index 9cd34530be..a268db107a 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise {
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
-const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
+const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory']
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
@@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
await input.fill(prompt)
await input.press('Enter')
- // startSession chain: session mounts, composer moves to the bottom.
- // Regression pin (P0, 585671106): this send used to white-screen the tree
- // (scope tag lost to a duplicate inlined runtime instance) — body going
- // near-empty here means that class of bug is back.
+ // The first send must keep the session tree mounted; a near-empty body
+ // reveals a duplicate runtime bundle with incompatible scope tags.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([])
await page.waitForFunction(
diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
new file mode 100644
index 0000000000..21218b459d
--- /dev/null
+++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
@@ -0,0 +1,95 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":6,"time":1784973850889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
+{"type":"assistant/chunk","seq":7,"time":1784973851088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
+{"type":"assistant/chunk","seq":8,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
+{"type":"assistant/chunk","seq":9,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":10,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
+{"type":"assistant/chunk","seq":11,"time":1784973851089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}
+{"type":"assistant/chunk","seq":12,"time":1784973851107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
+{"type":"assistant/chunk","seq":13,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}}
+{"type":"assistant/chunk","seq":14,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
+{"type":"assistant/chunk","seq":15,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
+{"type":"assistant/chunk","seq":16,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
+{"type":"assistant/chunk","seq":17,"time":1784973851108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":18,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":19,"time":1784973851135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":20,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
+{"type":"assistant/chunk","seq":21,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":22,"time":1784973851136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":24,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":""}}}
+{"type":"assistant/chunk","seq":25,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"{"}}}
+{"type":"assistant/chunk","seq":26,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":27,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"command"}}}
+{"type":"assistant/chunk","seq":28,"time":1784973851244,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":29,"time":1784973851245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
+{"type":"assistant/chunk","seq":30,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":31,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"echo"}}}
+{"type":"assistant/chunk","seq":32,"time":1784973851271,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" WEB"}}}
+{"type":"assistant/chunk","seq":33,"time":1784973851272,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_E"}}}
+{"type":"assistant/chunk","seq":34,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"2"}}}
+{"type":"assistant/chunk","seq":35,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
+{"type":"assistant/chunk","seq":36,"time":1784973851299,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"_OK"}}}
+{"type":"assistant/chunk","seq":37,"time":1784973851300,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":38,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":", "}}}
+{"type":"assistant/chunk","seq":39,"time":1784973851326,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":40,"time":1784973851352,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"description"}}}
+{"type":"assistant/chunk","seq":41,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":42,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":": "}}}
+{"type":"assistant/chunk","seq":43,"time":1784973851353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":44,"time":1784973851379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"E"}}}
+{"type":"assistant/chunk","seq":45,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"cho"}}}
+{"type":"assistant/chunk","seq":46,"time":1784973851406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" the"}}}
+{"type":"assistant/chunk","seq":47,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" test"}}}
+{"type":"assistant/chunk","seq":48,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":" string"}}}
+{"type":"assistant/chunk","seq":49,"time":1784973851435,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":50,"time":1784973851461,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","argumentsDelta":"}"}}}
+{"type":"assistant/chunk","seq":51,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}}
+{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
+{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
+{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
+{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
+{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
+{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":61,"time":1784973852195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
+{"type":"assistant/chunk","seq":62,"time":1784973852309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}
+{"type":"assistant/chunk","seq":63,"time":1784973852338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}}
+{"type":"assistant/chunk","seq":64,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
+{"type":"assistant/chunk","seq":65,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
+{"type":"assistant/chunk","seq":66,"time":1784973852339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}}
+{"type":"assistant/chunk","seq":67,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":68,"time":1784973852370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}}
+{"type":"assistant/chunk","seq":69,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}}
+{"type":"assistant/chunk","seq":70,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}
+{"type":"assistant/chunk","seq":71,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}}
+{"type":"assistant/chunk","seq":72,"time":1784973852371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
+{"type":"assistant/chunk","seq":73,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"assistant/chunk","seq":74,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
+{"type":"assistant/chunk","seq":75,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}
+{"type":"assistant/chunk","seq":76,"time":1784973852398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
+{"type":"assistant/chunk","seq":77,"time":1784973852428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
+{"type":"assistant/chunk","seq":78,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":79,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":80,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":81,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
+{"type":"assistant/chunk","seq":82,"time":1784973852429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":83,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"assistant/chunk","seq":84,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":85,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
+{"type":"assistant/chunk","seq":86,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":87,"time":1784973852459,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."}}}}
+{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
+{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
+{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
new file mode 100644
index 0000000000..a6d1203d9d
--- /dev/null
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -0,0 +1,31 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Use the bash tool to" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
+- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
+ - img
+ - text: Think The user wants me to run a simple bash command and reply with "DONE".
+- text: Echo the test string
+- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
+ - img
+ - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
+- paragraph: DONE
+- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Plan mode":
+ - option "Plan" [selected]
+ - option "Agent"
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- combobox "Model":
+ - option "DeepSeek-V4-Pro High" [selected]
+ - option "DeepSeek-V4-Pro"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl
new file mode 100644
index 0000000000..27e31004bc
--- /dev/null
+++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl
@@ -0,0 +1,112 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
+{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
+{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":6,"time":1784974101297,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
+{"type":"assistant/chunk","seq":7,"time":1784974101422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
+{"type":"assistant/chunk","seq":8,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
+{"type":"assistant/chunk","seq":9,"time":1784974101452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":10,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
+{"type":"assistant/chunk","seq":11,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
+{"type":"assistant/chunk","seq":12,"time":1784974101453,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
+{"type":"assistant/chunk","seq":13,"time":1784974101483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
+{"type":"assistant/chunk","seq":14,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
+{"type":"assistant/chunk","seq":15,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
+{"type":"assistant/chunk","seq":16,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
+{"type":"assistant/chunk","seq":17,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
+{"type":"assistant/chunk","seq":18,"time":1784974101484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
+{"type":"assistant/chunk","seq":19,"time":1784974101514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":20,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":21,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":22,"time":1784974101515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
+{"type":"assistant/chunk","seq":23,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":24,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"assistant/chunk","seq":25,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}
+{"type":"assistant/chunk","seq":26,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":27,"time":1784974101545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}
+{"type":"assistant/chunk","seq":28,"time":1784974101546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}}
+{"type":"assistant/chunk","seq":29,"time":1784974101576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}}
+{"type":"assistant/chunk","seq":30,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}}
+{"type":"assistant/chunk","seq":31,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parallel"}}}
+{"type":"assistant/chunk","seq":32,"time":1784974101577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":34,"time":1784974101667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":""}}}
+{"type":"assistant/chunk","seq":35,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"{"}}}
+{"type":"assistant/chunk","seq":36,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":37,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"file"}}}
+{"type":"assistant/chunk","seq":38,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"_path"}}}
+{"type":"assistant/chunk","seq":39,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":40,"time":1784974101697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":": "}}}
+{"type":"assistant/chunk","seq":41,"time":1784974101726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":42,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"a"}}}
+{"type":"assistant/chunk","seq":43,"time":1784974101727,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":".txt"}}}
+{"type":"assistant/chunk","seq":44,"time":1784974101756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":45,"time":1784974101757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","argumentsDelta":"}"}}}
+{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":47,"time":1784974101822,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":""}}}
+{"type":"assistant/chunk","seq":48,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"{"}}}
+{"type":"assistant/chunk","seq":49,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":50,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"file"}}}
+{"type":"assistant/chunk","seq":51,"time":1784974101849,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"_path"}}}
+{"type":"assistant/chunk","seq":52,"time":1784974101850,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":53,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":": "}}}
+{"type":"assistant/chunk","seq":54,"time":1784974101881,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":55,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"b"}}}
+{"type":"assistant/chunk","seq":56,"time":1784974101882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":".txt"}}}
+{"type":"assistant/chunk","seq":57,"time":1784974101908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"\""}}}
+{"type":"assistant/chunk","seq":58,"time":1784974101909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","argumentsDelta":"}"}}}
+{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}}
+{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
+{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
+{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
+{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
+{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
+{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
+{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"}
+{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"}
+{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":72,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}}
+{"type":"assistant/chunk","seq":73,"time":1784974102505,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}}
+{"type":"assistant/chunk","seq":74,"time":1784974102534,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}}
+{"type":"assistant/chunk","seq":75,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}}
+{"type":"assistant/chunk","seq":76,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
+{"type":"assistant/chunk","seq":77,"time":1784974102535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":78,"time":1784974102565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
+{"type":"assistant/chunk","seq":79,"time":1784974102595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
+{"type":"assistant/chunk","seq":80,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
+{"type":"assistant/chunk","seq":81,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":82,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}}
+{"type":"assistant/chunk","seq":83,"time":1784974102596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
+{"type":"assistant/chunk","seq":84,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
+{"type":"assistant/chunk","seq":85,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}}
+{"type":"assistant/chunk","seq":86,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
+{"type":"assistant/chunk","seq":87,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
+{"type":"assistant/chunk","seq":88,"time":1784974102625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":89,"time":1784974102626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}}
+{"type":"assistant/chunk","seq":90,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
+{"type":"assistant/chunk","seq":91,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
+{"type":"assistant/chunk","seq":92,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}}
+{"type":"assistant/chunk","seq":93,"time":1784974102656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}}
+{"type":"assistant/chunk","seq":94,"time":1784974102689,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":95,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":96,"time":1784974102690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
+{"type":"assistant/chunk","seq":97,"time":1784974102716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":98,"time":1784974102717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}}
+{"type":"assistant/chunk","seq":99,"time":1784974102748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}}
+{"type":"assistant/chunk","seq":100,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
+{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}}
+{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
+{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"}
+{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
new file mode 100644
index 0000000000..c919fccec1
--- /dev/null
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -0,0 +1,36 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Use the read tool twice" [disabled]
+ - text: · 1 turns
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - tab "Waterfall"
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
+- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
+ - img
+ - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
+- button:
+ - img
+- text: Read a.txt
+- button:
+ - img
+- text: Read b.txt
+- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
+ - img
+ - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
+- paragraph: DONE
+- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+ - img
+- combobox "Plan mode":
+ - option "Plan" [selected]
+ - option "Agent"
+- combobox "Access mode":
+ - option "Read-only" [selected]
+ - option "Read-write"
+- combobox "Model":
+ - option "DeepSeek-V4-Pro High" [selected]
+ - option "DeepSeek-V4-Pro"
+- button "Send message" [disabled]
diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts
new file mode 100644
index 0000000000..2130d27306
--- /dev/null
+++ b/apps/web/tests/workspace-flow.snapshot.ts
@@ -0,0 +1,326 @@
+// @vitest-environment jsdom
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+ { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
+ { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
+ { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
+ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+ {
+ id: '@deepseek-ai/dsh-client-ui-workspace',
+ dir: 'ui-workspace',
+ url: '/plugins/ui-workspace.js',
+ rev: 'fx',
+ inject: [
+ '@deepseek-ai/dsh-client-runtime',
+ '@deepseek-ai/dsh-client-ui-conversation',
+ '@deepseek-ai/dsh-client-ui-sidebar',
+ ],
+ },
+ { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+ plugin.url,
+ readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+ __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+ __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+ observe(): void {}
+ disconnect(): void {}
+ unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+ localStorage.clear()
+ document.title = 'DeepSeek Harness'
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ setTimeout(() => { callback(0) }, 0) as unknown as number)
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+ act(() => { unmount?.() })
+ unmount = undefined
+ cleanup()
+ delete win.__DSH_BOOT__
+ delete win.__ModuleLoader__
+ delete (globalThis as Record).__fxTiming
+ document.body.innerHTML = ''
+ document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+ document.title = ''
+ history.replaceState(null, '', '/')
+ vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against one keyless fixture branch. */
+function boot(search: string): void {
+ history.replaceState(null, '', `/${search}`)
+ const root = document.createElement('div')
+ root.id = 'root'
+ document.body.appendChild(root)
+ win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+ act(() => {
+ const entry = new AppWebEntry(root, {
+ fetchBundle: (url) => {
+ const code = bundles.get(url)
+ return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+ },
+ executeBundle: (code) => { (0, eval)(code) },
+ })
+ void entry.run()
+ unmount = () => { entry.dispose() }
+ })
+}
+
+/** Recreate the built client graph while preserving browser-persistent state. */
+function refresh(search: string): void {
+ act(() => { unmount?.() })
+ unmount = undefined
+ cleanup()
+ delete win.__DSH_BOOT__
+ delete win.__ModuleLoader__
+ delete (globalThis as Record).__fxTiming
+ document.body.innerHTML = ''
+ document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+ boot(search)
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/** Identify the interactive Workspace chip by its menu contract. */
+function workspaceChip(): HTMLElement {
+ const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
+ .find(element => element.getAttribute('aria-haspopup') === 'menu')
+ if (chip === undefined) throw new Error('Workspace chip missing')
+ return chip
+}
+
+/** Wait for the runtime-owned controlled input to echo a browser edit. */
+async function setComposerText(composer: HTMLElement, value: string): Promise {
+ fireEvent.change(composer, { target: { value } })
+ await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
+}
+
+it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
+ boot('?fixture=empty')
+
+ const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await setComposerText(composer, 'keep this local')
+
+ expect({
+ headline: visibleText(screen.getByText("Let's start building")),
+ workspaceDraft: visibleText(workspaceChip()),
+ sidebar: visibleText(tree),
+ composerDisabled: (composer as HTMLTextAreaElement).disabled,
+ prompt: (composer as HTMLTextAreaElement).value,
+ }).toMatchInlineSnapshot(`
+ {
+ "composerDisabled": false,
+ "headline": "Let's start building",
+ "prompt": "keep this local",
+ "sidebar": "No sessions yet",
+ "workspaceDraft": "workspace",
+ }
+ `)
+})
+
+it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
+ boot('?fixture=empty')
+
+ await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const workspaceSection = screen.getByText('Workspaces').parentElement
+ if (workspaceSection === null) throw new Error('Workspace section missing')
+ fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
+
+ const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
+ fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
+ target: { value: 'nova' },
+ })
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
+
+ const tree = await screen.findByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
+ const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
+ if (group === null || draft === null) throw new Error('created Workspace projection missing')
+
+ expect({
+ workspace: visibleText(group),
+ draft: visibleText(draft),
+ draftSelected: draft.getAttribute('aria-selected'),
+ composerWorkspace: visibleText(workspaceChip()),
+ }).toMatchInlineSnapshot(`
+ {
+ "composerWorkspace": "nova",
+ "draft": "New session",
+ "draftSelected": "true",
+ "workspace": "nova1 session",
+ }
+ `)
+})
+
+it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
+ boot('?fixture')
+
+ const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await setComposerText(composer, 'discard this page-local draft')
+ const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
+ if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
+
+ const before = {
+ workspace: visibleText(beforeGroup),
+ draft: visibleText(within(tree).getByText('New session')),
+ prompt: (composer as HTMLTextAreaElement).value,
+ }
+
+ refresh('?fixture')
+
+ const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
+ const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
+ if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
+
+ expect({
+ before,
+ after: {
+ workspace: visibleText(afterGroup),
+ replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
+ prompt: (refreshedComposer as HTMLTextAreaElement).value,
+ },
+ }).toMatchInlineSnapshot(`
+ {
+ "after": {
+ "prompt": "",
+ "replacementDraft": "New session",
+ "workspace": "fixture4 sessions",
+ },
+ "before": {
+ "draft": "New session",
+ "prompt": "discard this page-local draft",
+ "workspace": "fixture4 sessions",
+ },
+ }
+ `)
+})
+
+it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
+ boot('?fixture&fixtureAttach=fail')
+
+ const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ await setComposerText(composer, 'keep this cwd-only session')
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
+
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
+ const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
+ const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ const ungroupedSection = ungroupedGroup?.parentElement
+ if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
+ throw new Error('Workspace or Ungrouped projection missing')
+ }
+ const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
+ const retained = screen.getByDisplayValue('keep this cwd-only session')
+
+ expect({
+ workspace: visibleText(workspaceGroup),
+ ungrouped: visibleText(ungroupedGroup),
+ session: within(session).getByText('fixture', { exact: true }).textContent,
+ sessionSelected: session.getAttribute('aria-selected'),
+ prompt: (retained as HTMLTextAreaElement).value,
+ }).toMatchInlineSnapshot(`
+ {
+ "prompt": "keep this cwd-only session",
+ "session": "fixture",
+ "sessionSelected": "true",
+ "ungrouped": "Ungrouped1 session",
+ "workspace": "fixture3 sessions",
+ }
+ `)
+})
+
+it('materializes the automatic Workspace and Session on the first successful send', async () => {
+ boot('?fixture=empty')
+
+ const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ await setComposerText(composer, 'build a lighthouse')
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
+
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
+ await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
+ const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ const session = within(tree).getByRole('treeitem', { selected: true })
+ if (group === null) throw new Error('materialized Workspace projection missing')
+
+ expect({
+ workspace: visibleText(group),
+ session: within(session).getByText('workspace', { exact: true }).textContent,
+ sessionSelected: session.getAttribute('aria-selected'),
+ promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
+ }).toMatchInlineSnapshot(`
+ {
+ "promptVisible": "build a lighthouse",
+ "session": "workspace",
+ "sessionSelected": "true",
+ "workspace": "workspace1 session",
+ }
+ `)
+})
+
+it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
+ boot('?fixture=empty&fixturePrompt=reject')
+
+ const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+ await setComposerText(composer, 'do not lose this')
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
+
+ const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
+ const retained = screen.getByDisplayValue('do not lose this')
+ const tree = screen.getByRole('tree', { name: 'Sessions' })
+ await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
+ const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
+ const session = within(tree).getByRole('treeitem', { selected: true })
+ if (group === null) throw new Error('rejected-send Workspace projection missing')
+
+ expect({
+ workspace: visibleText(group),
+ session: within(session).getByText('workspace', { exact: true }).textContent,
+ error: visibleText(alert),
+ prompt: (retained as HTMLTextAreaElement).value,
+ }).toMatchInlineSnapshot(`
+ {
+ "error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
+ "prompt": "do not lose this",
+ "session": "workspace",
+ "workspace": "workspace1 session",
+ }
+ `)
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index d0af0d4641..54c5673451 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -17,6 +17,15 @@
"src",
"tests"
],
+ // The web e2e lane (scaffold + replay specs) boots the host spine and reads
+ // its Context merges — host-plane programs, checked in tsconfig.host.json;
+ // this client-registered project must not also hold them (one program
+ // cannot see both sides of the cordis Context merges).
+ "exclude": [
+ "tests/scaffold.ts",
+ "tests/replay-round-trip.e2e.ts",
+ "tests/seeded-history.e2e.ts"
+ ],
"references": [
{
"path": "../../packages/client/web"
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index fa5c8328db..1798b46908 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-architecture.md: 38a0b704e3ed8be11b59743a0f76f7ce09c06323
-architecture.zh.md: e0e381a81d7d1ac8e18d2b8fac28b1d3d8903ff5
+architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d
+architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744
diff --git a/docs/architecture.md b/docs/architecture.md
index 38a0b704e3..b426891c04 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -43,7 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` interface: concrete live-preferred exact/filter/trace; exactly two abstract FTS methods via `session-query-sqlite` |
+| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks |
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index e0e381a81d..13feefb685 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -43,7 +43,7 @@
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` 接口:精确检索、过滤与追踪采用实时优先的具体实现;恰有两个全文搜索方法为抽象方法,由 `session-query-sqlite` 实现 |
+| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 6363add08e..0f2dfd1610 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -40,10 +40,13 @@ flowchart LR
pkg_storage_json["storage-json"]
pkg_storage_sqlite["storage-sqlite"]
pkg_storage_domain["storage-domain"]
+ svc_storageDomain["ctx.storageDomain
Domain data facility"]
pkg_workspace["workspace"]
svc_workspace["ctx.workspace
Workspace entity registry"]
+ pkg_apiproxy["apiproxy"]
svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
+ pkg_tool_session_query["tool-session-query"]
svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"]
pkg_tui["tui"]
pkg_session_title["session-title"]
@@ -180,6 +183,7 @@ flowchart LR
pkg_spill --> svc_spillStore
pkg_spill_local --> svc_spillStore
pkg_storage --> svc_storage
+ pkg_storage_domain --> svc_storageDomain
pkg_storage_json --> svc_storage
pkg_storage_sqlite --> svc_storage
pkg_subagent --> svc_subagents
@@ -241,6 +245,7 @@ flowchart LR
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
svc_sessionQuery --> pkg_session_reference
+ svc_sessionQuery --> pkg_tool_session_query
svc_sessionReferences --> pkg_tui
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
@@ -253,7 +258,7 @@ flowchart LR
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
- svc_storage --> pkg_workspace
+ svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
svc_systemPrompt --> pkg_agent_loop
@@ -282,6 +287,7 @@ flowchart LR
svc_web --> pkg_tool_web
svc_workflows --> pkg_tool_ralph
svc_workflows --> pkg_tool_workflow
+ svc_workspace --> pkg_apiproxy
svc_fs -. event gate .-> pkg_fs_policy
```
@@ -293,9 +299,10 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
-| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
-| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. |
-| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. |
+| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
+| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
+| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
+| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 367b986d4c..9880761894 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -57,7 +57,7 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable
- /** Directory for JSONL sessions. Defaults to `./.sessions`. */
+ /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
@@ -80,7 +80,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
-Source: [`packages/examples/acp-demo/src/index.ts:37`](../packages/examples/acp-demo/src/index.ts)
+Source: [`packages/examples/acp-demo/src/index.ts:39`](../packages/examples/acp-demo/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -487,19 +487,21 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-c
## `@deepseek-ai/dsh-host-apiproxy`
-Requires: `agents` · `sessions` · `tools` · `userInteraction`
+Requires: `agents` · `sessions` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
-/** Gateway plugin config: the host-level default agent routing. */
+/** Gateway plugin config: host-level agent routing and Workspace creation root. */
export interface Config {
/** Default provider route for created/resumed agents. */
provider: string
/** Default model id. */
model: string
+ /** Parent directory for name-created Workspaces; defaults to the Host cwd. */
+ workspaceRoot?: string
}
```
-Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts)
+Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts)
## `@deepseek-ai/dsh-host-webserver`
@@ -659,6 +661,8 @@ export interface Config {
childFiles?: string[]
/** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
providers?: ReplayProviderConfig[]
+ /** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */
+ paceMs?: number
}
/** One provider route exposed by the replay adapter. */
@@ -684,7 +688,7 @@ export interface ReplayModelConfig {
}
```
-Source: [`packages/support/llm-replay/src/index.ts:392`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:459`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
@@ -753,12 +757,12 @@ Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src
Requires: `tools`
```ts config-catalog
-/** Discriminated union of all supported MCP transport configurations. */
+/** Configuration for one stdio or Streamable HTTP MCP server. */
export type Config = StdioConfig | StreamableHttpConfig
/** Config for connecting to an MCP server via a spawned child process over stdio. */
export interface StdioConfig {
- /** Transport type: spawn a child process and communicate over stdio. */
+ /** Selects child-process stdio transport. */
transport: 'stdio'
/**
* Stable local namespace for this server's model-facing tool names
@@ -766,21 +770,21 @@ export interface StdioConfig {
* unique across live mcp-client instances.
*/
serverName: string
- /** Executable to spawn. */
+ /** Executable used to start the server. */
command: string
- /** Arguments passed to the command. */
+ /** Arguments passed directly, without shell interpolation. */
args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */
env: Record
/** Working directory for the child process. */
cwd: string
- /** Timeout per callTool invocation (ms). */
+ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
}
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig {
- /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
+ /** Selects Streamable HTTP transport. */
transport: 'streamable-http'
/**
* Stable local namespace for this server's model-facing tool names
@@ -788,11 +792,11 @@ export interface StreamableHttpConfig {
* unique across live mcp-client instances.
*/
serverName: string
- /** MCP server URL. */
+ /** MCP endpoint URL. */
url: string
- /** Extra headers (e.g. auth tokens). */
+ /** Additional headers attached to MCP requests. */
headers: Record
- /** Timeout per callTool invocation (ms). */
+ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
}
```
@@ -843,7 +847,7 @@ export interface PlanModeConfig {
}
```
-Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts)
## `@deepseek-ai/dsh-pty-local`
@@ -978,9 +982,9 @@ export interface Config {
/**
* Root directory for all session files. Required (no default): a default of
* `process.cwd()` would scatter session files as the process's cwd changes
- * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An
- * existing root must be a readable directory; an absent root is created on
- * first materialization.
+ * (bash calls, subprocesses). Sessions group under human-readable project
+ * directories, then per-session directories. An existing root must be a
+ * readable directory; an absent root is created on first materialization.
*/
root: string
/**
@@ -1062,6 +1066,8 @@ export interface Config extends SessionQueryConfig {
maxLimit?: number
/** Maximum snippet length in Unicode code points. Defaults to 240. */
snippetChars?: number
+ /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */
+ persistedInspectConcurrency?: number
}
/** Supported SQLite journal modes. */
@@ -1070,7 +1076,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
-Source: [`packages/session-query/session-query-sqlite/src/index.ts:74`](../packages/session-query/session-query-sqlite/src/index.ts)
+Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-reference`
@@ -1217,7 +1223,7 @@ export interface Config {
}
```
-Source: [`packages/storage/storage-domain/src/index.ts:45`](../packages/storage/storage-domain/src/index.ts)
+Source: [`packages/storage/storage-domain/src/index.ts:52`](../packages/storage/storage-domain/src/index.ts)
## `@deepseek-ai/dsh-storage-json`
@@ -1544,6 +1550,22 @@ export interface Config {
Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts)
+## `@deepseek-ai/dsh-tool-session-query`
+
+Requires: `tools` · `systemPrompt` · `sessionQuery`
+
+```ts config-catalog
+/** Deployment-owned search count and timeout bounds. */
+export interface Config {
+ /** Maximum authorized hits returned by one search call. Defaults to 100. */
+ maxSearchResults?: number
+ /** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */
+ searchTimeoutMs?: number
+}
+```
+
+Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../packages/session-query/tool-session-query/src/index.ts)
+
## `@deepseek-ai/dsh-tool-skill`
Requires: `tools` · `skills`
@@ -1786,8 +1808,8 @@ export interface Config {
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
- * door). Set it to a command that resumes via this app's env var, e.g.
- * `RESUME_SESSION_ID={session} dsh`.
+ * door). Set it to a command that resumes the session, e.g.
+ * `dsh --resume {session}`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */
@@ -2015,15 +2037,19 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
-- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts))
+- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
- `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
@@ -2040,7 +2066,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
-- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
+- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
## Seam packages (not directly loadable)
@@ -2071,8 +2097,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
-- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
+- `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml
similarity index 66%
rename from .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml
rename to docs/cookbook/maintaining-dsh-code-review.i18n.yaml
index a06145f092..b983ffd591 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml
+++ b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml
@@ -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
-2026-07-21-tui-banner-sweep.md: c146424d53e75a72b63e346f87a5bbd206d67350
-2026-07-21-tui-banner-sweep.zh.md: 01cc153e88f067b7b8d2eb6317648f3892fe8a5a
+maintaining-dsh-code-review.md: 2b5d0d926ae922f2650daac33cf35991cb71c5e5
+maintaining-dsh-code-review.zh.md: c0e8b64fde3a67174878b4b0665712c9ba2e67c0
diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md
index 8af449b749..2b5d0d926a 100644
--- a/docs/cookbook/maintaining-dsh-code-review.md
+++ b/docs/cookbook/maintaining-dsh-code-review.md
@@ -1,5 +1,7 @@
# Maintaining the dsh-code-review skill
+English | [中文](maintaining-dsh-code-review.zh.md)
+
The [`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill is kept current by a single designated operator running a private periodic maintenance tool. This cookbook is the entry point for that operator — and for anyone taking over the role — and for repo contributors who want to understand why skill updates arrive as small periodic PRs rather than one-off audits. The workflow itself is specified in the [human-review skill-maintenance Agent Note](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md).
## What the maintainer receives
diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md
new file mode 100644
index 0000000000..c0e8b64fde
--- /dev/null
+++ b/docs/cookbook/maintaining-dsh-code-review.zh.md
@@ -0,0 +1,64 @@
+# 维护 dsh-code-review skill
+
+[English](maintaining-dsh-code-review.md) | 中文
+
+[`dsh-code-review`](../../.agents/skills/dsh-code-review/SKILL.md) skill(技能)由一名指定操作员通过私有的周期维护工具持续更新。本实操手册(cookbook)既是该操作员和接任者的入口,也帮助仓库贡献者理解为何 skill 更新会以小型周期 PR(Pull Request)的形式出现,而不是一次性审计。工作流本身由[人工评审 skill 维护 Agent Note(agent 决策记录)](../../.agents/notes/proposed/process/2026-07-13-human-review-skill-maintenance.md)规定。
+
+## 维护者会收到什么
+
+每天运行私有工具,并使用 2 个 UTC 日的重叠窗口;在拟议的调度器完成验收运行之前,操作员按相同频率手动调用包装脚本。每周手动恢复运行使用 7 日窗口。工作流会:
+
+1. 选择指定窗口内合并、且合并 commit 可从 `origin/master` 到达的 PR(每天运行默认选择 2 个 UTC 日,每周运行选择 7 日)。合并 commit 无法到达的 PR(例如父分支被 squash 的堆叠分支),或超出 250 个 commit 获取上限的 PR,会记录到 `skipped-pulls.json` 并跳过,不会中止本次运行。
+2. 收集合并前带 commit 锚点的人工评审反馈(行内评论和评审提交),然后比较反馈时与最终落地的 PR patch。它不获取 PR 会话评论,因为 GitHub 当前状态无法为这些评论提供可抵抗 force-push 的反馈时基线;它也不会把只存在于目标分支的变更作为采纳证据。
+3. 两个独立配置的评审适配器先对来源和采纳情况分类,再根据当前 skill 对双方一致认定已采纳的条目分类。
+4. 主适配器起草完整修订版 `SKILL.md`;两个适配器评审同一份 diff;只要仍有阻塞发现,循环就会继续,直到双方批准。
+5. 工具声明成功前,会针对候选版本运行 `pnpm run doc-sync` 和 `pnpm run lint`。
+
+每次运行都把产物保存在操作员的机器上。保存的 diff、候选 `SKILL.md` 和提升 manifest(元数据清单)按时间戳命名,存放在 `~/dsh-code-review-outputs/` 下。manifest 记录源 master commit 与 skill blob、源反馈 ID 和 URL、已落地证据范围、适配器裁决和门禁结果;每个适配器的原始 I/O 留在私有临时目录中,该目录路径会写入通知和 `~/Library/Logs/dsh-code-review-maintainer/` 下的每日日志。维护 worktree 在每次运行后都会恢复为干净状态,避免操作员直接在维护副本中编辑。
+
+## 操作员如何处理候选 diff
+
+某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote ` 提示的通知。
+
+1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者契约规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。
+
+ ```sh
+ ls ~/dsh-code-review-outputs/ # every candidate ever produced
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.diff
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.SKILL.md
+ less ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.manifest.json
+ ```
+
+2. **与运行产物交叉核验。** 提升 manifest 会把每条拟议规则映射到源反馈和已落地证据;每个适配器的详细 I/O、共识和采纳证据位于本次运行的私有临时目录中(路径见日志)。至少抽查一个候选项:链接的人工评论是否确实支持新增规则?链接的 PR 是否确实采纳了它?
+
+3. **从三种处理方式中选择一种:**
+ - **丢弃。** 删除保存的候选版本。下一次运行会依据届时的当前 skill,重新考虑同一份反馈。
+
+ ```sh
+ rm ~/dsh-code-review-outputs/2026-07-16T02-00-00Z.{diff,SKILL.md,manifest.json}
+ ```
+ - **暂存成批。** 如果更新很小,可以把候选版本留待与后续版本合并。源 skill 检查仍然适用;如果 `master` 先发生变化,请重新运行分析,或手动 rebase 并重新评审 diff。
+ - **提升。** 在仓库的干净 `master` checkout 中运行提升辅助工具。它会刷新 `master`、验证当前 skill 与记录的源 blob 一致、应用保存的 diff,并创建一份 draft PR,其正文包含 manifest 的来源摘要。如果 skill 已发生漂移,它会停止而不是覆盖更新后的指导;操作员仍需在 GitHub 上评审 PR,并选择合并或关闭。
+
+ ```sh
+ cd ~/path/to/deepseek-harness # clean master
+ dsh-code-review-promote 2026-07-16T02-00-00Z
+ ```
+
+4. **不要逐字提交适配器输出。** 提升过程中可以进行小幅编辑,例如收紧措辞、移除只有结合源 PR 上下文才有意义的示例、把规则并入现有规则。这些编辑是预期行为,也保留了工作流所依赖的「评审者判断」。合并前应在该分支上修订这些改动。
+
+## 运行未产出候选版本时
+
+只要每个非空分类阶段都至少产生一个有效的适配器结果,这就是常见情况。工具会在每日日志中记录「无候选版本」,不发送通知(避免提醒疲劳),然后继续。某天没有 skill 更新,说明工作流运行正常,而不是停滞。
+
+## 中断与交接
+
+该机制运行在一台机器上。操作员应随时处理以下中断:
+
+- **错过每日运行。** 2 日重叠窗口会自动覆盖一次漏跑;更长的间隔可通过设置 `DSH_CODE_REVIEW_SINCE=` 手动运行包装脚本来恢复。重叠窗口具有幂等性:当前 skill 已包含的指导会被归类为 `covered`,不会再次成为候选项。
+- **适配器提供方中断。** 当两个评审命令解析为逐字节相同的可执行文件时,工具会拒绝运行。某个批次的适配器响应未通过 schema 或 ID 校验时,该批次会整体 fail-closed(其中每个条目都标记为不明确),运行则继续;原始输出会保留以便调试。如果任一适配器在某项操作的所有非空批次中都未产生有效结果,本次运行就会失败、写入失败记录并通知操作员;它绝不会把提供方完全中断折叠成「无候选版本」。
+- **交接给另一名维护者。** 新建一篇取代当前记录的后续 Agent Note:要么把机制移入仓库,要么记录新操作员的私有设置。不要暗中转交工具;Agent Note 的风险章节已把「单维护者关键人风险」列为交接必须记录决策的原因。
+
+## 操作员的私有设置位于何处
+
+工具源代码、评审适配器、提供方凭据和调度器属于操作员的私有基础设施,按设计位于本仓库之外(参见 Agent Note 的「机制位于何处」章节)。本实操手册和 Agent Note 描述的是**工作流保证什么**;这些保证**如何**实现则属于私有基础设施问题。如果你是新操作员,应以 Agent Note 的 `## Proposal` 各节作为实现依据。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 99bb0e97cd..259c79ead2 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -850,7 +850,7 @@ set(agent: Agent, active: boolean): void
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts)
## `ctx.pty` — `PtyService`
@@ -1056,15 +1056,17 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven
* This read is serialized with writes for the same id and returns detached
* values, so observers cannot mutate backend-owned state.
* @param id - the persisted session to inspect.
+ * @param signal - optional cancellation for queued and backend read work.
* @returns the header and valid stored event prefix exactly as observed.
*/
-abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
+abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
/**
* Lightweight listing from metadata, without a full-log parse.
+ * @param signal - optional cancellation for backend listing work.
* @returns one header per materialized session.
*/
-abstract list(): Promise
+abstract list(signal?: AbortSignal): Promise
/**
* List materialized sessions with cheap per-log change tokens.
@@ -1073,9 +1075,10 @@ abstract list(): Promise
* successful mutating {@link load} repair changes the next listed revision.
* Revisions also distinguish independently backed stores so backend-local
* counters cannot compare equal across different persistence sources.
+ * @param signal - optional cancellation for backend snapshot-listing work.
* @returns one header and opaque revision per materialized session without loading full logs.
*/
-abstract listSnapshots(): Promise
+abstract listSnapshots(signal?: AbortSignal): Promise
```
Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md)
@@ -1101,15 +1104,16 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec
* Search events within one live-preferred logical session.
* @param request - target session, query text, filters, page size, and cursor.
* @param exec - optional cancellation control.
- * @returns matching event hits in deterministic relevance order.
+ * @returns matching event hits and their target header from one indexed generation.
*/
-abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>
+abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise
/**
* List the complete logical corpus using live-preferred records.
+ * @param signal - optional cancellation for persistence listing.
* @returns deterministic newest-first cloned session records.
*/
-listSessions(): Promise
+listSessions(signal?: AbortSignal): Promise
/**
* Read and replay-validate one complete logical session log without making it live.
@@ -1122,16 +1126,37 @@ async readSession(sessionId: SessionId): Promise
/**
* Filter the complete logical corpus with provider-independent predicates.
* @param filters - ANDed session metadata and availability clauses.
+ * @param signal - optional cancellation for persistence listing.
* @returns matching cloned records in deterministic newest-first order.
*/
-async filterSessions(filters: readonly SessionResultFilter[]): Promise
+async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise
/**
* Fold the latest log-backed title from one live-preferred logical session.
* @param sessionId - live or persisted session id to read.
+ * @param signal - optional cancellation for source resolution and title folding.
* @returns latest title snapshot, or `undefined` when the log has no title event.
*/
-async readTitle(sessionId: SessionId): Promise
+async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise
+
+/**
+ * Fold the latest title and return its source header from one corpus observation.
+ * @param sessionId - live or persisted session id to read.
+ * @param signal - optional cancellation for source resolution and title folding.
+ * @returns cloned source header and optional latest title snapshot.
+ */
+async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise
+
+/**
+ * Fold titles for unique sessions from one cancellable corpus observation.
+ *
+ * Results preserve first-occurrence input order. Operational failures stay
+ * isolated per session, while cancellation rejects the complete operation.
+ * @param sessionIds - live or persisted session ids to observe.
+ * @param signal - optional cancellation shared by all source reads.
+ * @returns one fulfilled or rejected result per unique requested id.
+ */
+async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise
/**
* List lightweight raw-log event records for one logical session.
@@ -1159,30 +1184,33 @@ async readSurface(sessionId: SessionId): Promise
/**
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.
+ * @param signal - optional cancellation for persistence listing.
* @returns a complete lineage or an explicit unresolved parent boundary.
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
*/
-async traceSession(sessionId: SessionId): Promise
+async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise
/**
* Trace one event's direct positional and provenance relationships.
* @param request - target session id and event seq.
- * @returns direct links plus the target's positional replacement chain.
+ * @param signal - optional cancellation for persisted source resolution.
+ * @returns source header, direct links, and the target's positional replacement chain.
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
*/
-async traceEvent(request: SessionEventTraceRequest): Promise
+async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise
/**
* Read one full event plus a bounded raw-log context window.
* @param request - target session/seq and context sizes.
+ * @param signal - optional cancellation for persisted source resolution.
* @returns cloned target and neighboring events.
*/
-async readEvent(request: SessionEventReadRequest): Promise
+async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise
```
-Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
+Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
-Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts)
+Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts)
## `ctx.sessionReferences` — `SessionReferenceService`
@@ -1490,7 +1518,50 @@ mount(form: K, facility: StorageForms[K]): () => v
form(form: K): StorageForms[K]
```
-Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts)
+Source: [`packages/storage/storage/src/index.ts:47`](../../packages/storage/storage/src/index.ts)
+
+## `ctx.storageDomain` — `DomainFacility`
+
+The mounted domain facility. Opens declared domains over routed backends; one facility instance owns the open-domain table and enforces single-open per domain name.
+
+```ts cordis-catalog
+/**
+ * Open one declared domain. Steps, each failing the whole call: reject a
+ * name that is already open (`already-open`); resolve the backend route
+ * (`backend-not-found` passes through from the hub); require its `kv` facet
+ * (`facet-unsupported`); open the unit projected from the spec (backend
+ * `version-mismatch`/`malformed-medium` pass through); load and validate
+ * every stored record against the spec's zod schemas (`invalid-record`
+ * with the offending table and key); construct the domain.
+ *
+ * Lifecycle: the CALLER owns the returned handle and closes it via
+ * `Domain.close()` (typically as its own `ctx.effect` disposer) — the
+ * facility does not tie the domain to any consumer fiber. Domains still
+ * open when the facility unmounts are closed by the plugin disposer.
+ * @param spec - The domain declaration, typically from `defineDomain`.
+ * @returns the opened domain handle, typed by the spec.
+ */
+async open(spec: S): Promise>
+
+/**
+ * Look up an open domain by name, untyped. Diagnostic surface (the package
+ * invariant cross-checks change events against live domain state); typed
+ * consumers hold the handle returned by {@link open}.
+ * @param name - Domain name.
+ * @returns the open domain runtime, or `undefined` when not open.
+ */
+get(name: string): DomainImpl | undefined
+
+/**
+ * Close every domain still open on this facility. The unmount path for
+ * consumers that never called `Domain.close()` themselves; closing is
+ * idempotent, so double-closing an already-closed domain is harmless.
+ * @returns resolution after every unit is released.
+ */
+async closeAll(): Promise
+```
+
+Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts)
## `ctx.subagents` — `SubagentService`
@@ -1954,49 +2025,48 @@ Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/
## `ctx.workspace` — `WorkspaceRegistry`
-The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered.
-
-There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note).
+Durable workspace registry. Startup waits for `sessionPersistence`, builds one canonical-cwd header index, and completes the one-time history bootstrap before the service becomes active. The persistence dependency is mandatory so an unavailable peer can never be mistaken for an empty history and commit the initialized marker.
```ts cordis-catalog
/**
- * Create a workspace over an existing directory. The path is canonicalized
- * through `fs.realpath` first — a nonexistent path rejects with the
- * original `ENOENT`, a path resolving to anything but a directory rejects,
- * and a canonical path already owned by another workspace (including a
- * symlink resolving to it) rejects.
- * @param path - Directory the workspace points at; canonicalized before storing.
- * @param title - Display title; defaults to `basename` of the canonical path.
- * @returns the created workspace after durability.
+ * Create or reuse a workspace for an existing directory. The path is
+ * canonicalized through `fs.realpath`; a nonexistent path rejects with the
+ * original error and a non-directory rejects. Repeated calls for the same
+ * canonical path return the existing entity without changing its title.
+ * A newly created workspace is prepended to the durable registry order.
+ * A different canonical path cannot create a duplicate display title.
+ * @param path - Existing directory to own, in any path spelling.
+ * @param title - Display title used only when a new record is created.
+ * @returns the existing or newly durable workspace.
*/
async create(path: string, title?: string): Promise
/**
* Look up a workspace by id.
- * @param id - The workspace id.
+ * @param id - Workspace id.
* @returns the workspace, or `undefined` when unknown.
*/
get(id: WorkspaceId): Workspace | undefined
/**
- * Snapshot of all workspaces, in load-then-creation order.
- * @returns a fresh array of the cached entities.
+ * Synchronous workspace projection in durable registry order. Every
+ * entity's `sessionIds` getter is already filtered by the startup/live
+ * canonical-cwd header index; this method performs no persistence reads.
+ * @returns a fresh ordered array of workspace entities.
*/
list(): Workspace[]
/**
- * Resolve a workspace by directory path, through the same `fs.realpath`
- * canon as {@link create} (hence async). A path that does not exist rejects
- * with the original error — a missing directory has no canonical form to
- * compare (a workspace whose recorded directory vanished is only reachable
- * by id; see `Workspace.status`).
- * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).
- * @returns the owning workspace, or `undefined` when none matches.
+ * Resolve by canonical directory path without creating or mutating a
+ * workspace. A missing path rejects during `realpath`; an existing unowned
+ * directory returns `undefined`.
+ * @param path - Existing directory path in any spelling.
+ * @returns the workspace owning the canonical path, when one exists.
*/
async resolveByPath(path: string): Promise
```
-Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts)
+Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
## Inherited `ctx` members (cordis core + loader/hmr/timer)
diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml
similarity index 65%
rename from .agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml
rename to docs/cordis-tutorial/01-first-plugin.i18n.yaml
index 28ecd2a765..6ab341e8f5 100644
--- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml
+++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml
@@ -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
-2026-07-14-time-context-plugin.md: 189f75fc12fe12e9dec56fc71ea901ec2eaa8b19
-2026-07-14-time-context-plugin.zh.md: 12671cb891531627fffabb7bd91a1532bc3de6b9
+01-first-plugin.md: b730b7ad7dc9ebd2e5dc8af4f7a83bd58ac7d4d8
+01-first-plugin.zh.md: 1e6901c048f5268ddead0faca85eaf5eef9c7533
diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md
index 084f9964e1..b730b7ad7d 100644
--- a/docs/cordis-tutorial/01-first-plugin.md
+++ b/docs/cordis-tutorial/01-first-plugin.md
@@ -1,5 +1,7 @@
# 1. Your first plugin
+English | [中文](01-first-plugin.zh.md)
+
In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes.
## Write the plugin
diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md
new file mode 100644
index 0000000000..1e6901c048
--- /dev/null
+++ b/docs/cordis-tutorial/01-first-plugin.zh.md
@@ -0,0 +1,95 @@
+# 1. 编写第一个插件
+
+[English](01-first-plugin.md) | 中文
+
+在本教程使用的 loader 配置中,Cordis 插件模块通过命名导出提供 `apply` 函数。Cordis 加载模块时,会用一个 **上下文** 调用 `apply`;该上下文就是 `ctx` 对象,插件通过它注册自己贡献的所有内容。
+
+## 编写插件
+
+在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'hello'
+
+export function apply(ctx: Context) {
+ console.log('hello from my first plugin')
+}
+```
+
+`name` 导出项是可选的显示元数据;它用于在诊断信息中标识插件。
+
+## 组合应用
+
+本教程的启动器通过配置组装应用。创建 `cordis.yml`:
+
+```yaml
+- name: './hello.ts'
+```
+
+该文件是一组 Cordis 配置项的列表。`name` 是模块指定符,可以是相对路径或 NPM 包(package)名;loader 会挂载每个配置项。各项会并发启动,因此它们在列表中的位置不保证插件的加载先后;顺序由服务依赖(`inject`,参见[第 3 章](03-services.md))决定,而非文件中的位置。
+
+## 运行
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+预期输出:
+
+```
+hello from my first plugin
+```
+
+当没有任何内容继续运行时,进程会自行退出。具体过程如下:
+
+1. 启动器创建根 `Context`,并挂载 **Loader** 插件。
+2. Loader 读取 `cordis.yml`,解析 `./hello.ts`,然后将其作为子插件挂载。
+3. Cordis 调用你的 `apply(ctx)`。
+
+你的文件中没有框架启动代码:插件描述自己的贡献,`cordis.yml` 则组合应用。例如,[TUI agent(智能体)](../../examples/tui-agent/cordis.yml) 就是一个更长的插件组合。
+
+## 其他两种插件形态
+
+函数是最常见的形态,但 Cordis 接受三种形态:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+// 1. Function plugin (what you just wrote).
+export function apply(ctx: Context) {}
+
+// 2. Object plugin: an object with an `apply` method.
+export const objectPlugin = {
+ name: 'object-plugin',
+ apply(ctx: Context) {},
+}
+
+// 3. Class plugin: a Service subclass (covered in chapter 3).
+export class MyService extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'myTutorialService')
+ }
+}
+```
+
+在你需要公开服务之前,请一直使用函数形态;[第 3 章](03-services.md)介绍了何时应当使用类形态。
+
+## 尝试制造错误
+
+让 `apply` 抛出异常:
+
+```ts ignore-check
+export function apply(ctx: Context) {
+ throw new Error('apply exploded')
+}
+```
+
+再次运行:进程会因该错误而终止。插件加载失败必须明确报错,不会仅跳过该配置项。
+
+还需要尽早了解一个例外:如果某个配置项的模块无法被 **解析**,例如路径或包名拼写错误,Cordis 会通过 logger 服务报告错误,而不会使进程崩溃。在启动阶段,这条报告可能在 console 导出器开始观察之前丢失。如果新增配置项似乎没有任何效果,请先检查拼写。
+
+下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml
new file mode 100644
index 0000000000..6d9e4bb6fd
--- /dev/null
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml
@@ -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
+02-lifecycle-and-effects.md: f1b39e06e9d25c51ab2d76503025e2b6ffe90c73
+02-lifecycle-and-effects.zh.md: a6021ed7475a0045d480810747244274eb5b4198
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md
index 68a0f12ec8..f1b39e06e9 100644
--- a/docs/cordis-tutorial/02-lifecycle-and-effects.md
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md
@@ -1,5 +1,7 @@
# 2. Lifecycle and effects
+English | [中文](02-lifecycle-and-effects.zh.md)
+
A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`.
## Effects
diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md
new file mode 100644
index 0000000000..a6021ed747
--- /dev/null
+++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md
@@ -0,0 +1,98 @@
+# 2. 生命周期与 effect
+
+[English](02-lifecycle-and-effects.md) | 中文
+
+Cordis 插件可能因配置编辑、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 `ctx.effect()` 中。
+
+## Effect
+
+对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 `ctx.effect()` 中并返回 disposer(dispose(资源释放)函数):
+
+创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'lifecycle-demo'
+
+function heartbeat(ctx: Context) {
+ console.log('heartbeat plugin loading')
+ ctx.effect(() => {
+ const timer = setInterval(() => console.log('tick'), 200)
+ return () => {
+ clearInterval(timer)
+ console.log('heartbeat cleaned up')
+ }
+ })
+}
+
+export function apply(ctx: Context) {
+ // Mount a child plugin and keep its fiber to dispose it later.
+ const fiber = ctx.plugin(heartbeat)
+ // The demo timer is itself an effect: if THIS plugin is unloaded first,
+ // the pending callback is cancelled instead of firing on a dead app.
+ ctx.effect(() => {
+ const timer = setTimeout(async () => {
+ await fiber.dispose()
+ console.log('disposed')
+ process.exit(0)
+ }, 700)
+ return () => clearTimeout(timer)
+ })
+}
+```
+
+让 `cordis.yml` 指向该文件:
+
+```yaml
+- name: './lifecycle.ts'
+```
+
+运行(`node --import tsx ../../vendor/cordis/bin.js`)后会得到:
+
+```
+heartbeat plugin loading
+tick
+tick
+tick
+heartbeat cleaned up
+disposed
+```
+
+请留意三点:
+
+- `ctx.plugin(heartbeat)` 会把一个**来自代码**的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要 `apply` 方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求 `apply` 方法,例如 `ctx.plugin({ apply(ctx) { /* ... */ } })`。调用会返回一个 **fiber**,即一个已加载插件实例的运行时句柄。
+- effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
+- `fiber.dispose()` 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。
+
+## Fiber 状态机
+
+每个已加载插件实例都拥有一个 fiber,并依次经过以下状态:
+
+```
+PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
+ ↘ FAILED
+```
+
+- **PENDING**:已经声明,但所需服务(第 3 章)尚不可用。
+- **LOADING / ACTIVE**:`apply` 正在运行/已经完成。
+- **FAILED**:`apply` 或配置校验抛出异常。
+- **UNLOADING / DISPOSED**:disposer 正在运行/一切均已拆除。
+
+你会在[第 6 章](06-composition-and-hmr.md)再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。
+
+## 已经属于 effect 的操作
+
+你很少需要亲自编写 `ctx.effect()`,因为内置注册 API 本身已经是 effect:
+
+- `ctx.on(event, listener)`:监听器会在卸载时移除([第 4 章](04-events.md))。
+- `ctx.plugin(child)`:子插件会随父插件一同 dispose。
+- 服务注册属于 effect。`ctx.tools.register(...)` 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动回卷([第 7 章](07-into-the-harness.md))。
+
+对于 Cordis 不管理的资源,应在 `ctx.effect()` 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。
+
+有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个**异步** disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。
+
+下一章:[服务](03-services.md):插件如何共享功能。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml
new file mode 100644
index 0000000000..d42d5eb250
--- /dev/null
+++ b/docs/cordis-tutorial/03-services.i18n.yaml
@@ -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
+03-services.md: 5848132c6ad18338fa893954d45fc20005db6199
+03-services.zh.md: 3c77d0451df9062f1a344e7474e6be141b709197
diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md
index 9f62003e99..5848132c6a 100644
--- a/docs/cordis-tutorial/03-services.md
+++ b/docs/cordis-tutorial/03-services.md
@@ -1,5 +1,7 @@
# 3. Services
+English | [中文](03-services.zh.md)
+
A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer.
## Provide a service
diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md
new file mode 100644
index 0000000000..3c77d0451d
--- /dev/null
+++ b/docs/cordis-tutorial/03-services.zh.md
@@ -0,0 +1,98 @@
+# 3. 服务
+
+[English](03-services.md) | 中文
+
+**服务**是一个插件提供、其他插件通过 `ctx` 消费的命名功能。在 harness 中,`ctx.tools`、`ctx.llm` 和 `ctx.agents` 都是服务。消费方只命名 `'tools'` 之类的功能,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。
+
+## 提供服务
+
+创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Context {
+ greeter: GreeterService
+ }
+}
+
+export class GreeterService extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'greeter')
+ }
+
+ greet(who: string) {
+ return `Hello, ${who}!`
+ }
+}
+
+export const name = 'greeter'
+
+export function apply(ctx: Context) {
+ ctx.plugin(GreeterService)
+}
+```
+
+两部分协同工作:
+
+- **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。
+- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。
+
+`Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。
+
+## 使用 `inject` 消费服务
+
+创建 `consumer.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'consumer'
+export const inject = ['greeter']
+
+export function apply(ctx: Context) {
+ console.log(ctx.greeter.greet('world'))
+}
+```
+
+`inject` 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 `apply` 内可以保证 `ctx.greeter` 已经就绪。`cordis.yml` 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。
+
+组合并运行:
+
+```yaml
+- name: './greeter.ts'
+- name: './consumer.ts'
+```
+
+```
+Hello, world!
+```
+
+交换 `cordis.yml` 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 `./greeter.ts`:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出。[第 6 章](06-composition-and-hmr.md)介绍如何诊断这种状态。
+
+## 加载后仍会跟踪依赖关系
+
+`inject` 并非一次性的启动检查。如果应用运行期间所需服务消失,例如提供方被卸载或热替换,每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect([第 2 章](02-lifecycle-and-effects.md)),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会回卷。
+
+这也是配置中可以替换服务的原因:卸载 `dsh-bash-local` 配置项,挂载另一个 `bash` 提供方,所有注入 `'bash'` 的插件都会干净地重启并使用新实现。
+
+## 可选依赖
+
+`inject` 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 `inject`,并在使用处探测:
+
+```ts ignore-check
+export function apply(ctx: Context) {
+ // undefined when no provider is loaded; the plugin still runs.
+ const greeter = ctx.get('greeter')
+ console.log(greeter?.greet('maybe') ?? 'no greeter available')
+}
+```
+
+## 命名
+
+每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 `tools` 和 `llm` 等普通名称);生成的[服务目录](../cordis-catalog/services.md)列出 harness 注册的每个名称。
+
+下一章:[事件](04-events.md):无需共享服务即可通信。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml
new file mode 100644
index 0000000000..cd1fc962a7
--- /dev/null
+++ b/docs/cordis-tutorial/04-events.i18n.yaml
@@ -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
+04-events.md: 18f39dc1b693e5fb7e1793ec4b7dcac9cf24db95
+04-events.zh.md: f55a61ff2f43ea42968893d07eb92ea0613b921a
diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md
index 4c14ba5313..18f39dc1b6 100644
--- a/docs/cordis-tutorial/04-events.md
+++ b/docs/cordis-tutorial/04-events.md
@@ -1,5 +1,7 @@
# 4. Events
+English | [中文](04-events.zh.md)
+
Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.
## Declare, emit, listen
diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md
new file mode 100644
index 0000000000..f55a61ff2f
--- /dev/null
+++ b/docs/cordis-tutorial/04-events.zh.md
@@ -0,0 +1,144 @@
+# 4. 事件
+
+[English](04-events.md) | 中文
+
+服务支持直接调用;**事件**让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。
+
+## 声明、发出与监听
+
+创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务:
+
+```ts
+import { Service, type Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Context {
+ stats: StatsService
+ }
+ interface Events {
+ 'stats/report'(name: string, count: number): void
+ }
+}
+
+export class StatsService extends Service {
+ private counts = new Map()
+
+ constructor(ctx: Context) {
+ super(ctx, 'stats')
+ }
+
+ bump(name: string) {
+ const next = (this.counts.get(name) ?? 0) + 1
+ this.counts.set(name, next)
+ this.ctx.emit('stats/report', name, next)
+ }
+}
+
+export const name = 'stats'
+
+export function apply(ctx: Context) {
+ ctx.plugin(StatsService)
+}
+```
+
+`interface Events` 合并与第 3 章的 `interface Context` 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 `ctx.emit` 和 `ctx.on` 都具有完整类型。`namespace/action` 命名约定让扁平的事件命名空间保持易读。
+
+创建 `reporter.ts`:
+
+```ts ignore-check
+import type { Context } from 'cordis'
+import type {} from './stats.ts'
+
+export const name = 'reporter'
+export const inject = ['stats']
+
+export function apply(ctx: Context) {
+ ctx.on('stats/report', (name, count) => {
+ console.log(`[stats] ${name} -> ${count}`)
+ })
+ ctx.stats.bump('tool_call')
+ ctx.stats.bump('tool_call')
+ ctx.stats.bump('prompt')
+}
+```
+
+`import type {} from './stats.ts'` 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。组合并运行:
+
+```yaml
+- name: './stats.ts'
+- name: './reporter.ts'
+```
+
+```
+[stats] tool_call -> 1
+[stats] tool_call -> 2
+[stats] prompt -> 1
+```
+
+因为 `ctx.on()` 属于 effect,监听器会随插件一同消失,绝不需要手动维护 `removeListener`。
+
+## 分发模式
+
+`emit` 是 5 种分发模式之一。事件采用哪种模式是其契约的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路:
+
+| 模式 | 调用 | 语义 |
+|---|---|---|
+| emit | `ctx.emit(name, ...args)` | 同步广播;不会等待或收集返回的 promise 与值。 |
+| parallel | `await ctx.parallel(name, ...args)` | 所有监听器并发运行,并一同等待。 |
+| serial | `await ctx.serial(name, ...args)` | 监听器按顺序运行并等待;第一个非 `null`/`false`/`undefined` 返回值胜出,并停止后续监听器。 |
+| bail | `ctx.bail(name, ...args)` | serial 的同步版本。 |
+| waterfall(瀑布式事件) | `ctx.waterfall(name, ...args, next)` | 环绕中间件,见下文。 |
+
+每个 harness 事件都会在生成的[事件目录](../cordis-catalog/events.md)中记录其模式。
+
+## Waterfall:转换或短路
+
+waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+declare module 'cordis' {
+ interface Events {
+ 'demo/transform'(input: string, next: () => Promise): Promise
+ }
+}
+
+export const name = 'waterfall-demo'
+
+export function apply(ctx: Context) {
+ // Listener 1: wrap the downstream result.
+ ctx.on('demo/transform', async (input, next) => {
+ const downstream = await next()
+ return downstream.toUpperCase()
+ })
+
+ // Listener 2: short-circuit when it owns the decision.
+ ctx.on('demo/transform', async (input, next) => {
+ if (input.includes('blocked')) return '** blocked **'
+ return next()
+ })
+
+ void (async () => {
+ console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
+ console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
+ })()
+}
+```
+
+让 `cordis.yml` 只指向该文件并运行:
+
+```
+HELLO
+** BLOCKED **
+```
+
+按顺序看第二行如何产生:监听器 1 先运行并调用 `next()`,从而调用监听器 2;监听器 2 看到 `blocked` 后直接返回而不调用 `next()`,因此最内层默认逻辑(传给 `ctx.waterfall` 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。
+
+由此得到一项纪律:**只负责观察或标注的 waterfall 监听器必须调用 `next()`**;不调用就直接返回代表有意短路。如果日志监听器忘记调用 `next()`,会悄无声息地吞掉所有下游的默认行为。这一点极其重要,已成为本仓库的常设规则([waterfall 语义](../cordis-primer.md#cordis-waterfall-semantics))。
+
+harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) 允许插件替换模型调用配置,[`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) 允许策略代替用户作答。
+
+下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml
new file mode 100644
index 0000000000..deb6f119c2
--- /dev/null
+++ b/docs/cordis-tutorial/05-config.i18n.yaml
@@ -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
+05-config.md: fc19add239636fa9e7071d9c77e48595caec1f08
+05-config.zh.md: 52a75e40672c9a08d285677dd14dcd404b925e5a
diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md
index 09aaf8971d..fc19add239 100644
--- a/docs/cordis-tutorial/05-config.md
+++ b/docs/cordis-tutorial/05-config.md
@@ -1,5 +1,7 @@
# 5. Configuration
+English | [中文](05-config.zh.md)
+
Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured.
## A configurable plugin
diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md
new file mode 100644
index 0000000000..52a75e4067
--- /dev/null
+++ b/docs/cordis-tutorial/05-config.zh.md
@@ -0,0 +1,84 @@
+# 5. 配置
+
+[English](05-config.md) | 中文
+
+每个 `cordis.yml` 配置项都可以携带 `config` 块,插件则声明一个 schema,在运行 `apply` 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。
+
+## 可配置插件
+
+创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+import Schema from 'schemastery'
+
+export const name = 'config-demo'
+
+export interface Config {
+ greeting: string
+ targets: string[]
+}
+
+export const Config: Schema = Schema.object({
+ greeting: Schema.string().default('Hello'),
+ targets: Schema.array(String).default(['world']),
+})
+
+export function apply(ctx: Context, config: Config) {
+ for (const target of config.targets) {
+ console.log(`${config.greeting}, ${target}!`)
+ }
+}
+```
+
+导出的 `Config` 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 [Schemastery](https://github.com/shigma/schemastery) 定义 schema;Cordis 本身接受任意 [Standard Schema](https://standardschema.dev/) 验证器,因此将普通对象导出为 `Config` 无法工作。
+
+对其进行配置:
+
+```yaml
+- name: './config-demo.ts'
+ config:
+ targets: ['alpha', 'beta']
+```
+
+运行:
+
+```
+Hello, alpha!
+Hello, beta!
+```
+
+未提供 `greeting`,因此 schema 默认值会将其补齐:`apply` 始终会收到完整且经过验证的配置。
+
+## 明确报错
+
+现在向它传入无效内容:
+
+```yaml
+- name: './config-demo.ts'
+ config:
+ targets: 'not-an-array'
+```
+
+```
+ValidationError: invalid config:
+ - $.targets expected array but got not-an-array (at targets)
+```
+
+插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的 schema 有效配置命名了不可用的资源或提供方,该插件也应当在能解析该引用时立即拒绝。
+
+## 计算得到的配置值
+
+本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值,例如从环境中读取 API key:
+
+```yaml
+- name: '@deepseek-ai/dsh-llm-deepseek'
+ config:
+ apiKey: !!js process.env.DEEPSEEK_API_KEY
+```
+
+`!!js` **仅在 `config` 内有效**。配置项元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
+
+下一章:[组合与 HMR](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml
new file mode 100644
index 0000000000..01f9345de3
--- /dev/null
+++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml
@@ -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
+06-composition-and-hmr.md: 66d6a9d93fe39baa881940ba32388979e2678505
+06-composition-and-hmr.zh.md: ebe63fc26607ae6d9344c4795a7975496ed901b5
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md
index bb236cc169..66d6a9d93f 100644
--- a/docs/cordis-tutorial/06-composition-and-hmr.md
+++ b/docs/cordis-tutorial/06-composition-and-hmr.md
@@ -1,5 +1,7 @@
# 6. Composition and HMR
+English | [中文](06-composition-and-hmr.zh.md)
+
Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads.
## Entries are more than a name
diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md
new file mode 100644
index 0000000000..ebe63fc266
--- /dev/null
+++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md
@@ -0,0 +1,113 @@
+# 6. 组合与 HMR(热模块替换)
+
+[English](06-composition-and-hmr.md) | 中文
+
+到目前为止构建的每项功能都是插件,`cordis.yml` 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。
+
+## 配置项不只有名称
+
+配置项除了 `name` 和 `config`,还接受其他元数据:
+
+```yaml
+- id: greeter # stable identity for this entry
+ name: './greeter.ts'
+- id: consumer
+ name: './consumer.ts'
+ disabled: true # keep the entry, skip mounting it
+```
+
+`id` 为配置项提供稳定标识,使 loader 能区分修改现有配置项与先删除再添加。`disabled: true` 会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。
+
+组可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;`isolate` 则为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的 `bash`,互不影响。这些概念值得在用到之前先了解;[Cordis 入门](../cordis-primer.md)和[服务隔离示例](../user/develop/framework/service.md#service-isolation)介绍了详细内容。
+
+## 热模块替换
+
+卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。
+
+在 `tmp/cordis-tutorial` 中编写 `cordis.yml`:
+
+```yaml
+- id: logger
+ name: '@cordisjs/plugin-logger-console'
+- id: timer
+ name: '@cordisjs/plugin-timer'
+- id: hmr
+ name: '@cordisjs/plugin-hmr'
+ config:
+ root: ['.']
+- id: hello
+ name: './hello.ts'
+```
+
+列表中增加了两个支持插件:HMR 通过 Cordis logger 服务记录日志,因此没有 console exporter 时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。
+
+HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis:
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+现在编辑 `hello.ts`,修改日志消息并保存:
+
+```
+hello from my first plugin
+2026-07-22 15:44:36 [I] hmr watching [ '.' ]
+2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
+hello from my EDITED plugin
+```
+
+旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,`apply` 再次运行。按 Ctrl-C 停止进程。编辑 `cordis.yml` 本身也会触发更新:loader 按 `id` 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 `id` 的原因:不带该字段的配置项在每次读取时都会获得一个新生成的 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为先删除再添加并重新挂载。
+
+## 诊断始终无法加载的插件
+
+依赖驱动加载也有另一面:如果插件的 `inject` 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。
+
+你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`:
+
+```ts
+import { FiberState, type Context } from 'cordis'
+
+export const name = 'diagnose'
+
+export function apply(ctx: Context) {
+ setTimeout(() => {
+ for (const runtime of ctx.registry.values()) {
+ for (const fiber of runtime.fibers) {
+ if (fiber.state === FiberState.PENDING) {
+ console.log(`${fiber.name} is PENDING — a required service is missing`)
+ }
+ }
+ }
+ }, 500)
+}
+```
+
+再创建一个依赖无法满足的插件 `needs-timer.ts`:
+
+```ts
+import type { Context } from 'cordis'
+
+export const name = 'needs-timer'
+export const inject = ['timer']
+
+export function apply(ctx: Context) {
+ console.log('needs-timer loaded')
+}
+```
+
+```yaml
+- name: './needs-timer.ts'
+- name: './diagnose.ts'
+```
+
+运行它(直接执行 `node --import tsx ../../vendor/cordis/bin.js`,按 Ctrl-C 停止):
+
+```
+needs-timer is PENDING — a required service is missing
+```
+
+`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。
+
+下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml
new file mode 100644
index 0000000000..c85bcad755
--- /dev/null
+++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml
@@ -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
+07-into-the-harness.md: 6ec42c50fe5059955734fe7bc46117538dafaffc
+07-into-the-harness.zh.md: 32b21b008837e2972a53db9d893788dc6a7de9a9
diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md
index a86c538d48..6ec42c50fe 100644
--- a/docs/cordis-tutorial/07-into-the-harness.md
+++ b/docs/cordis-tutorial/07-into-the-harness.md
@@ -1,5 +1,7 @@
# 7. Into the harness
+English | [中文](07-into-the-harness.zh.md)
+
This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model.
## A tool plugin
diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md
new file mode 100644
index 0000000000..32b21b0088
--- /dev/null
+++ b/docs/cordis-tutorial/07-into-the-harness.zh.md
@@ -0,0 +1,107 @@
+# 7. 进入 harness
+
+[English](07-into-the-harness.md) | 中文
+
+本章会向 harness 的 `tools` 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。
+
+## 工具插件
+
+创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中:
+
+```ts
+import type { Context } from 'cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { CallId } from '@deepseek-ai/dsh-llm'
+
+export const name = 'greet-tool'
+export const inject = ['tools']
+
+export function apply(ctx: Context) {
+ ctx.tools.register(defineTool({
+ name: 'greet',
+ description: 'Greet the named person.',
+ parameters: {
+ name: { type: 'string', required: true, description: 'Who to greet' },
+ },
+ output: {
+ schema: { type: 'string' },
+ render: (_args, value) => [{ type: 'text', text: value }],
+ },
+ async execute(args) {
+ return `Hello, ${args.name}!`
+ },
+ }))
+
+ // Drive one call through the real execution pipeline, standing in for
+ // the model. CallId brands the correlation id a provider would issue.
+ void (async () => {
+ const result = await ctx.tools.execute({
+ callId: CallId('demo-1'),
+ name: 'greet',
+ arguments: { name: 'Cordis' },
+ signal: new AbortController().signal,
+ })
+ console.log('tool replied:', JSON.stringify(result.content))
+ })()
+}
+```
+
+这里的每个模式都来自前几章:`inject: ['tools']`([第 3 章](03-services.md))会让插件等待工具注册表就绪;`ctx.tools.register(...)` 会把注册 disposer 附着到插件([第 2 章](02-lifecycle-and-effects.md)),因此卸载时会注销工具。`defineTool` 将 `parameters` 规约转换为向模型展示的 JSON Schema,推导 `args` 的类型,并在 `execute` 运行前校验模型提供的参数。工具返回由 `output.schema` 声明的规范值;`output.render` 则另行生成原生且持久的结果内容。
+
+## 观察插件
+
+创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用:
+
+```ts
+import type { Context } from 'cordis'
+import type {} from '@deepseek-ai/dsh-tools'
+
+export const name = 'tool-logger'
+export const inject = ['tools']
+
+export function apply(ctx: Context) {
+ ctx.on('tools/result', (exec, result) => {
+ const text = result.content
+ .map(block => (block.type === 'text' ? block.text : ''))
+ .join('')
+ console.log(`[tool-logger] ${exec.name} -> ${text}`)
+ })
+}
+```
+
+`import type {} from '@deepseek-ai/dsh-tools'` 行会引入该包的声明合并,使 `'tools/result'` 及其 payload 具有类型。这与第 4 章导入 `stats.ts` 的做法相同,只是扩展到了包级别。
+
+## 组合并运行
+
+```yaml
+- name: '@deepseek-ai/dsh-system-prompt'
+- name: '@deepseek-ai/dsh-tools'
+- name: './tool-logger.ts'
+- name: './greet-tool.ts'
+```
+
+`@deepseek-ai/dsh-tools` 会注入 `systemPrompt` 服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像[第 6 章](06-composition-and-hmr.md)所述那样保持 PENDING。
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+```
+[tool-logger] greet -> Hello, Cordis!
+tool replied: [{"type":"text","text":"Hello, Cordis!"}]
+```
+
+logger 会先触发:`tools/result` 在结果物化过程中发出,早于 `execute` 的 promise 向调用方返回结果。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。
+
+## 从这里走向完整 agent(智能体)
+
+真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。
+
+后续可以阅读:
+
+- [构建工具](../user/develop/basic/tool.md):深入了解 `defineTool`,包括呈现和更丰富的 schema。
+- [三层功能设计](../user/develop/practice/index.md):harness 如何组织可替换功能。
+- 生成的[服务](../cordis-catalog/services.md)与[事件](../cordis-catalog/events.md)目录:可以注入和监听的所有内容。
+- [架构](../architecture.md):这些插件所处的系统地图。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml
new file mode 100644
index 0000000000..275c700851
--- /dev/null
+++ b/docs/cordis-tutorial/index.i18n.yaml
@@ -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
+index.md: af622ad4e35829c6283c40f1b0019d7959dac973
+index.zh.md: 35bad552ecce9c0496b0ed88b041a8109c81945b
diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md
index 9cf3966117..af622ad4e3 100644
--- a/docs/cordis-tutorial/index.md
+++ b/docs/cordis-tutorial/index.md
@@ -1,5 +1,7 @@
# Cordis tutorial
+English | [中文](index.zh.md)
+
Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services.
The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output.
@@ -41,6 +43,8 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js))
6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads.
7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services.
+
+
## TypeScript notes
The examples use three TypeScript features beyond ordinary modern JavaScript:
diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md
new file mode 100644
index 0000000000..35bad552ec
--- /dev/null
+++ b/docs/cordis-tutorial/index.zh.md
@@ -0,0 +1,58 @@
+# Cordis 教程
+
+[English](index.md) | 中文
+
+Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行时,其中的每项能力,包括工具、LLM(大语言模型)适配器、文件访问乃至 agent loop(智能体循环)本身,都是挂载到共享上下文中的插件。本教程通过动手实践讲解 Cordis:每一章都是一个可以运行的示例,你将在本仓库内的临时目录中逐步构建它,最后把一个插件接入真实的 harness 服务。
+
+本教程面向 agent 开发者。你不需要深入掌握 TypeScript;下文的 [TypeScript 说明](#typescript-notes)会解释可能陌生的语法,并且每一章都会给出确切命令和预期输出。
+
+如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见生成的[事件](../cordis-catalog/events.md)与[服务](../cordis-catalog/services.md)目录,以及 [Cordis 核心 API](../cordis-catalog/core/context.md)页面。
+
+## 准备工作
+
+你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
+
+```sh
+git clone https://github.com/deepseek-harness/deepseek-harness.git
+cd deepseek-harness
+pnpm install
+```
+
+创建各章使用的临时目录。`tmp/` 已被 git 忽略,因此你在其中写入的任何内容都不会进入版本控制:
+
+```sh
+mkdir -p tmp/cordis-tutorial
+cd tmp/cordis-tutorial
+```
+
+每一章都从该目录运行同一条命令:
+
+```sh
+node --import tsx ../../vendor/cordis/bin.js
+```
+
+这个单文件启动器(见 [vendor/cordis/bin.js](../../vendor/cordis/bin.js))会创建根 `Context`、挂载 Loader 插件,并让它从当前目录加载 `./cordis.yml`。其余所有内容,包括有哪些插件以及如何配置它们,都来自你稍后将编写的 YAML 文件。`--import tsx` 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。
+
+## 章节
+
+1. [你的第一个插件](01-first-plugin.md):插件是函数,由 loader 挂载。
+2. [生命周期与 effect](02-lifecycle-and-effects.md):由 Cordis 管理的注册会在所属插件卸载时撤销。
+3. [服务](03-services.md):在 `ctx` 上公开一项能力,并通过 `inject` 依赖它。
+4. [事件](04-events.md):类型化事件、广播分发和 waterfall(瀑布式事件)的短路行为。
+5. [配置](05-config.md):读取 `cordis.yml` 中经过校验的配置,并在输入错误时快速失败。
+6. [组合与 HMR(热模块替换)](06-composition-and-hmr.md):把配置文件作为插件树,使用热重载,并诊断始终无法加载的插件。
+7. [进入 harness](07-into-the-harness.md):基于真实的 harness 服务注册一个可由模型调用的工具。
+
+
+
+## TypeScript 说明
+
+这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能:
+
+- **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。
+- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。
+- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。
+
+第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。
+
+[](https://github.com/deepseek-harness/deepseek-harness)
diff --git a/docs/core-data-structures/commands.i18n.yaml b/docs/core-data-structures/commands.i18n.yaml
new file mode 100644
index 0000000000..ba55abec39
--- /dev/null
+++ b/docs/core-data-structures/commands.i18n.yaml
@@ -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
+commands.md: 056c775f4c2e1586447db11821e5c7d56be01881
+commands.zh.md: 1a51305df356d8becf8c5517704dc375cdb8b585
diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md
index c36942cf18..056c775f4c 100644
--- a/docs/core-data-structures/commands.md
+++ b/docs/core-data-structures/commands.md
@@ -1,5 +1,7 @@
# Human Commands
+English | [中文](commands.zh.md)
+
The human-command seam of [`dsh-commands`](../../packages/ui/commands). Interactive adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command Agent Note](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations.
Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md
new file mode 100644
index 0000000000..1a51305df3
--- /dev/null
+++ b/docs/core-data-structures/commands.zh.md
@@ -0,0 +1,86 @@
+# 用户命令
+
+[English](commands.md) | 中文
+
+[`dsh-commands`](../../packages/ui/commands) 的用户命令 seam。交互式适配器用它发现插件拥有的命令,并针对确切的 agent(智能体)直接执行这些命令,而不创建模型消息。[命令 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) 负责分发与生命周期的决策依据;[包(package)README](../../packages/ui/commands/README.md) 负责组合方式与限制。
+
+来源:[`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts)
+
+## 输入元数据
+
+该 seam 公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
+
+```ts type-equiv
+/** Immutable metadata for a command's optional unstructured input. */
+interface CommandInputDescriptor {
+ /** Placeholder shown before the user supplies free-form input. */
+ readonly hint: string
+}
+```
+
+## 定义
+
+`CommandDefinition` 是由插件编写的注册定义。注册表会验证并冻结一份与原始注册对象脱离的生效定义。
+
+```ts type-equiv
+/** Plugin-owned command registration. */
+interface CommandDefinition {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+ /** Execute against the receiving agent without sending the command to the model. */
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise
+}
+```
+
+## 调用与结果
+
+适配器拥有取消操作,并传入确切的目标 agent。`rawInput` 紧接在解析后的名称之后,并保留适配器传入的分隔符与后缀。结果会直接呈现给 UI,而不是工具结果或会话事件。
+
+```ts type-equiv
+/** Invocation passed to one registered command handler. */
+interface CommandInvocation {
+ /** Exact agent whose human-facing surface received the command. */
+ readonly agent: Agent
+ /** Exact text following the registered command name, including separator whitespace. */
+ readonly rawInput: string
+ /** Cancellation signal owned by the dispatching UI request. */
+ readonly signal: AbortSignal
+}
+```
+
+```ts type-equiv
+/** Expected command outcome rendered directly by the dispatching UI. */
+type CommandResult =
+ | { readonly kind: 'success'; readonly text?: string }
+ | { readonly kind: 'error'; readonly text: string }
+```
+
+## 发现与解析视图
+
+作用域解析后,适配器会获得不含处理器的不可变描述符。`parseCommand()` 在注册表解析前返回 `ParsedCommand`;语法有效的输入仍可能指向不可用的命令。
+
+```ts type-equiv
+/** Handler-free immutable command view returned to UI adapters. */
+interface CommandDescriptor {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Human-readable summary used in discovery UI. */
+ readonly description: string
+ /** Optional free-form input hint advertised to capable clients. */
+ readonly input?: CommandInputDescriptor
+}
+```
+
+```ts type-equiv
+/** Syntactically valid slash command before registry resolution. */
+interface ParsedCommand {
+ /** Lowercase command name without the leading slash. */
+ readonly name: string
+ /** Exact text following the command name. */
+ readonly rawInput: string
+}
+```
diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml
new file mode 100644
index 0000000000..47dc0c1b1c
--- /dev/null
+++ b/docs/core-data-structures/goal.i18n.yaml
@@ -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
+goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9
+goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614
diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md
index c0351f2f77..2e8d296eed 100644
--- a/docs/core-data-structures/goal.md
+++ b/docs/core-data-structures/goal.md
@@ -1,5 +1,7 @@
# Same-session goals
+English | [中文](goal.zh.md)
+
Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts).
## Identity and lifecycle
diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md
new file mode 100644
index 0000000000..a9c946e7cd
--- /dev/null
+++ b/docs/core-data-structures/goal.zh.md
@@ -0,0 +1,145 @@
+# 同会话目标
+
+[English](goal.md) | 中文
+
+事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。
+
+## 标识与生命周期
+
+`GoalId` 是[品牌化 id](core.md#branded-ids)。调用方通过 `GoalRef` 修改一个确切修订版本;每次获准的持久变更都会递增修订号。
+
+```ts type-equiv
+/** Compare-and-set identity for one exact goal revision. */
+interface GoalRef {
+ /** Stable goal identity. */
+ readonly id: GoalId
+ /** Positive revision; every durable mutation increments it. */
+ readonly revision: number
+}
+```
+
+持久阶段回答目标发生了什么。进程本地激活状态则另行回答续跑消费方能否开始另一个 Round。
+
+```ts type-equiv
+/** Durable continuation phase. Activation is process-local and separate. */
+type GoalPhase =
+ | 'active'
+ | 'paused'
+ | 'blocked'
+ | 'complete'
+```
+
+阻塞是唯一表示「因问题而停止」的持久状态。由策略负责的阻塞原因会携带一个用于路由、稳定且采用 lower-kebab-case 的代码,以及一段供人和模型阅读的自由文本说明。
+
+```ts type-equiv
+/** Machine-routable and human-readable explanation for a blocked goal. */
+interface GoalBlockReason {
+ /** Stable lower-kebab-case classification chosen by the blocking policy. */
+ readonly code: string
+ /** Non-empty explanation shown to humans and models. */
+ readonly message: string
+}
+```
+
+```ts type-equiv
+/** Full durable state written by every non-clear goal mutation. */
+interface GoalSnapshot extends GoalRef {
+ /** Human-requested completion objective. */
+ readonly objective: string
+ /** Durable lifecycle phase. */
+ readonly phase: GoalPhase
+ /** Present exactly while `phase` is `blocked`. */
+ readonly blockedReason?: GoalBlockReason
+ /** Total admitted goal-round cap. */
+ readonly maxGoalRounds: number
+}
+```
+
+```ts type-equiv
+/** Current goal projection, including values derived from the session log. */
+interface GoalView extends GoalSnapshot {
+ /** Highest admitted round number for this goal. */
+ readonly roundsStarted: number
+ /** Epoch milliseconds of the create mutation. */
+ readonly createdAt: number
+ /** Epoch milliseconds of the latest mutation. */
+ readonly updatedAt: number
+ /** Process-local continuation eligibility; never persisted. */
+ readonly activation: GoalActivation
+}
+```
+
+## 持久变更
+
+每次变更都是 Round 编号为 0、来源为目标的 `user/message`,其元数据要么是完整快照,要么是清除墓碑。版本、元数据、目标来源和逐字渲染内容共同构成一项回放不变量。
+
+```ts type-equiv
+/** Full-snapshot goal mutation retained in a model-visible context event. */
+interface GoalSnapshotChangeMeta {
+ readonly kind: 'goal/change'
+ readonly version: 1
+ readonly operation: Exclude
+ readonly goal: GoalSnapshot
+ readonly roundsStarted: number
+ readonly createdAt: number
+ readonly updatedAt: number
+}
+```
+
+```ts type-equiv
+/** Tombstone retained when the current goal is cleared. */
+interface GoalClearChangeMeta {
+ readonly kind: 'goal/change'
+ readonly version: 1
+ readonly operation: 'clear'
+ readonly cleared: GoalRef
+ readonly clearedAt: number
+}
+```
+
+目标状态变更使用 Round `0`。续跑消费方会为每个获准的用户消息轮次标注正数且连续的 Round 编号和当前修订号;回放会拒绝编号缺口、陈旧修订号、已停止阶段和超出上限。
+
+```ts type-equiv
+/** Message attribution for durable goal state and continuation rounds. */
+interface GoalMessageSource {
+ readonly kind: 'goal'
+ readonly goalId: GoalId
+ readonly revision: number
+ /** Zero for state changes; positive for admitted continuation rounds. */
+ readonly round: number
+}
+```
+
+## 请求与通知
+
+创建操作会区分调用方省略的值与部署选择,`create()` 会在内部解析后者。编辑是局部替换,其运行时校验器要求至少提供一个字段。每条变更通知都会携带获准的操作和确切修订号;清除操作不带 `goal`。
+
+```ts type-equiv
+/** Input whose omitted round cap is resolved by the service configuration. */
+interface CreateGoalRequest {
+ readonly objective: string
+ readonly maxGoalRounds?: number
+}
+```
+
+```ts type-equiv
+/** Fields changed by an edit; at least one must be present. */
+interface EditGoalRequest {
+ readonly objective?: string
+ readonly maxGoalRounds?: number
+}
+```
+
+```ts type-equiv
+/** Live notification after one goal mutation has been accepted for logging. */
+interface GoalChanged {
+ readonly operation: GoalOperation
+ readonly ref: GoalRef
+ /** Absent for a clear tombstone. */
+ readonly goal?: GoalView
+}
+```
+
+## 服务行为
+
+[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更、叠加延迟注入,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用契约和面向模型的契约。
diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml
index 28191bd56c..5de51d2794 100644
--- a/docs/core-data-structures/llm-streaming.i18n.yaml
+++ b/docs/core-data-structures/llm-streaming.i18n.yaml
@@ -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
-llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb
-llm-streaming.zh.md: 740fa1f796088e63d0195cfbecf975adb236381c
+llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d
+llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222
diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md
index cb99c935ae..fb97e74a9e 100644
--- a/docs/core-data-structures/llm-streaming.md
+++ b/docs/core-data-structures/llm-streaming.md
@@ -63,6 +63,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt.
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
+- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md).
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter).
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.
diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md
index 740fa1f796..fda59a64ae 100644
--- a/docs/core-data-structures/llm-streaming.zh.md
+++ b/docs/core-data-structures/llm-streaming.zh.md
@@ -63,6 +63,7 @@ interface LlmFailure {
- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。
- **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。
- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
+- **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。
- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。
- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。
diff --git a/docs/core-data-structures/lsp.i18n.yaml b/docs/core-data-structures/lsp.i18n.yaml
new file mode 100644
index 0000000000..5ab99680eb
--- /dev/null
+++ b/docs/core-data-structures/lsp.i18n.yaml
@@ -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
+lsp.md: 62b133cbfdf521e067c56355664d7514a613397f
+lsp.zh.md: d7000970ec9114bcdad40a39d2712d48b9865529
diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md
index eb370f6e38..62b133cbfd 100644
--- a/docs/core-data-structures/lsp.md
+++ b/docs/core-data-structures/lsp.md
@@ -1,5 +1,7 @@
# LSP navigation
+English | [中文](lsp.zh.md)
+
The LSP seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation.
Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts)
diff --git a/docs/core-data-structures/lsp.zh.md b/docs/core-data-structures/lsp.zh.md
new file mode 100644
index 0000000000..d7000970ec
--- /dev/null
+++ b/docs/core-data-structures/lsp.zh.md
@@ -0,0 +1,165 @@
+# LSP 导航
+
+[English](lsp.md) | 中文
+
+LSP seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md):它在单一 `ctx.lsp` 服务上公开语义代码导航,并拆分到多个包(package):接口([dsh-lsp](../../packages/lsp/lsp),`ctx.lsp` + 提供方注册表)、通用实现([dsh-lsp-local](../../packages/lsp/lsp-local),经过配置的 stdio 语言服务器宿主)和消费方([dsh-tool-lsp](../../packages/lsp/tool-lsp),即 `lsp` 工具 schema)。LSP 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换提供方不会改变模型请求导航的方式。
+
+源文件:[`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts)
+
+## 操作与坐标
+
+seam 与模型恰好公开 4 项语义查询;该联合是闭合的,因此新增一项查询会通过编译强制要求同步修改 seam、提供方和工具。位置与范围采用从零开始的 UTF-16 坐标,与协议一致;面向模型的工具采用从 1 开始的光标约定,并在输入和输出时进行转换。
+
+```ts type-equiv
+/**
+ * The four semantic queries the seam and model expose. A closed union: adding an operation is a
+ * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are
+ * deliberately deferred (they need different schemas).
+ */
+type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
+```
+
+```ts type-equiv
+/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
+interface LspPosition {
+ /** Zero-based line. */
+ readonly line: number
+ /** Zero-based UTF-16 code-unit offset within the line. */
+ readonly character: number
+}
+```
+
+```ts type-equiv
+/** A zero-based UTF-16 half-open range `[start, end)`. */
+interface LspRange {
+ readonly start: LspPosition
+ readonly end: LspPosition
+}
+```
+
+## 请求
+
+每个字段都是必填项:`workspaceRoot` 由调用方提供,`languageId` 来自提供方注册而非请求,超时与结果上限由消费方决定。因此没有字段需要由实现提供默认值,也不存在 `resolve()` 步骤。提供方收到调用方请求和派生的 `languageId`;后者只用于同步瞬态文档,从不参与选择。
+
+```ts type-equiv
+/**
+ * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied,
+ * `languageId` comes from the provider registration (not here), and consumers own timeouts and
+ * result limits — so no field needs implementation defaulting and there is no `resolve()` step.
+ */
+interface LspQueryRequest {
+ /** Which semantic query to run. */
+ readonly operation: LspOperation
+ /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */
+ readonly filePath: string
+ /** The zero-based UTF-16 cursor position to query at. */
+ readonly position: LspPosition
+ /** The workspace root the provider resolves against and indexes; required, never defaulted. */
+ readonly workspaceRoot: string
+}
+```
+
+```ts type-equiv
+/**
+ * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId`
+ * the seam derived from the provider's extension mapping. The language id only synchronizes the
+ * transient document; it does not participate in selection.
+ */
+interface LspProviderQuery extends LspQueryRequest {
+ /** The LSP language id for `filePath`, from this provider's extension mapping. */
+ readonly languageId: string
+}
+```
+
+## 结果
+
+这是一个闭合的可辨识联合:导航操作规范化为 `locations`,`hover` 规范化为内容或 `null`。消费方使用 `switch` 对 `kind` 做穷尽处理,因此新增分支会使编译失败,直到完成处理。`findReferences` 始终包含声明;提供方在内部强制保证这一点,因此调用方没有对应 flag。`locations` 变体携带 `resolvedWorkspaceRoot`,即提供方对请求中 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根目录;调用方在相对化显示路径时应使用它,而不是可能经过符号链接的请求根目录。
+
+```ts type-equiv
+/** One resolved location: a document URI and the range within it. */
+interface LspLocation {
+ /** The target document URI (`file:` or otherwise), verbatim from the server. */
+ readonly uri: string
+ /** The range within the target document. */
+ readonly range: LspRange
+}
+```
+
+```ts type-equiv
+/** Normalized hover content, or `null` for no hover at the position. */
+interface LspHover {
+ /** The normalized hover text (markdown or plaintext, provider-joined). */
+ readonly contents: string
+ /** The range the hover applies to, when the server supplied one. */
+ readonly range?: LspRange
+}
+```
+
+```ts type-equiv
+/**
+ * The closed result union. Navigation operations (`goToDefinition`, `findReferences`,
+ * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
+ * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
+ *
+ * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
+ * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
+ * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
+ * otherwise a symlinked workspace misclassifies in-workspace results as external.
+ */
+type LspQueryResult =
+ | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
+ | { readonly kind: 'hover'; readonly hover: LspHover | null }
+```
+
+## 提供方与服务
+
+每个提供方拥有一个稳定的品牌化 `id`,以及一份互斥的、小写且以点开头的扩展名映射。`registerProvider` 会原子保留 id 和每个扩展名:注册无效或冲突时不发布任何内容;其 disposer 会释放所有保留项。每次查询独立选择提供方,且选择与顺序无关;没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。该 seam 不公开协议类型、进程或文档控制,也不提供通用 JSON-RPC 逃生口。
+
+```ts type-equiv
+/**
+ * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link
+ * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys).
+ * `findReferences` always includes declarations — the provider enforces this internally; callers
+ * get no flag.
+ */
+interface LspProvider {
+ /** Stable provider identity, reserved atomically with the extension mappings. */
+ readonly id: LspProviderId
+ /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
+ readonly extensionToLanguage: Readonly>
+ /**
+ * Run one query. The seam has already selected this provider and derived `languageId`.
+ * @param request - the resolved provider query (caller request + derived language id).
+ * @param signal - optional cancellation; the provider stops its own work when it aborts.
+ * @returns the normalized, closed-union result.
+ */
+ query(request: LspProviderQuery, signal?: AbortSignal): Promise
+}
+```
+
+```ts type-equiv
+/**
+ * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query
+ * execution; exposes exactly the four operations and no protocol escape hatch.
+ */
+interface LspService {
+ /**
+ * Register a provider, atomically reserving its id and every normalized extension. Any conflict
+ * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all
+ * reservations. Disposed with the calling fiber.
+ * @param provider - the backend to register.
+ * @returns a synchronous disposer releasing the id and all extension reservations.
+ */
+ registerProvider(provider: LspProvider): () => void
+ /**
+ * Select a provider by the file's extension and run one query. Selection is per-query and
+ * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`.
+ * @param request - the normalized query.
+ * @param signal - optional cancellation forwarded to the selected provider.
+ * @returns the normalized, closed-union result.
+ */
+ query(request: LspQueryRequest, signal?: AbortSignal): Promise
+}
+```
+
+`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。
diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml
index b7d8fda4e8..ebb8bfb6f3 100644
--- a/docs/core-data-structures/persistence.i18n.yaml
+++ b/docs/core-data-structures/persistence.i18n.yaml
@@ -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
-persistence.md: dc497fd85f44660c0a981579351b5cfbe0040a4d
-persistence.zh.md: 5236f4fe2ba8ad1be7e74bffafebfea19014d7aa
+persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e
+persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd
diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md
index dc497fd85f..4ec967873e 100644
--- a/docs/core-data-structures/persistence.md
+++ b/docs/core-data-structures/persistence.md
@@ -20,7 +20,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id
## `SessionLocation` — optional per-session artifact target
-`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
+`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
```ts type-equiv
/**
@@ -128,7 +128,7 @@ interface SessionPersistenceSnapshot {
## The backends
-Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
+Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync.
diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md
index 5236f4fe2b..3030ff2fe9 100644
--- a/docs/core-data-structures/persistence.zh.md
+++ b/docs/core-data-structures/persistence.zh.md
@@ -20,7 +20,7 @@
## `SessionLocation`——可选的逐会话产物目标
-`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。
+`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其项目/会话目录内 transcript(文本记录)的绝对路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。
```ts type-equiv
/**
diff --git a/docs/core-data-structures/pty.i18n.yaml b/docs/core-data-structures/pty.i18n.yaml
new file mode 100644
index 0000000000..6fa7d71d13
--- /dev/null
+++ b/docs/core-data-structures/pty.i18n.yaml
@@ -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
+pty.md: 97e1e662d1128ab0555e34f8284cf69d7d9d0d1a
+pty.zh.md: b17bc0d2c7bdb2a980df36824bd360ea975967f5
diff --git a/docs/core-data-structures/pty.md b/docs/core-data-structures/pty.md
index b205ec64c9..97e1e662d1 100644
--- a/docs/core-data-structures/pty.md
+++ b/docs/core-data-structures/pty.md
@@ -1,5 +1,7 @@
# Persistent PTY Sessions
+English | [中文](pty.zh.md)
+
Types shared by PTY backends, `ctx.pty`, and the model-facing consumer. The [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) owns the rationale; this page records the cross-package vocabulary from [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts).
## Identity and readiness
diff --git a/docs/core-data-structures/pty.zh.md b/docs/core-data-structures/pty.zh.md
new file mode 100644
index 0000000000..b17bc0d2c7
--- /dev/null
+++ b/docs/core-data-structures/pty.zh.md
@@ -0,0 +1,91 @@
+# 持久 PTY 会话
+
+[English](pty.md) | 中文
+
+PTY 后端、`ctx.pty` 与面向模型的消费方共享的类型。[持久 PTY Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 负责记录决策依据;本页记录来自 [`packages/pty/pty/src/types.ts`](../../packages/pty/pty/src/types.ts) 的跨包(package)词汇。
+
+## 标识与就绪
+
+`PtySessionId` 是由服务铸造的品牌化 id。可选名称是拥有者本地的显示元数据;授权比较的是确切的所属 `Agent`,而不是名称或猜测的 id。
+
+`PtyWaitReason` 说明一次发送为何返回。它与 `PtySessionStatus` 无关:一次发送可能因静默或超时而返回,但顶层 shell 仍然存活;`session_exit` 表示该 shell 已退出,而不是某个任意的前台子进程已退出。
+
+```ts type-equiv
+/** Why one interactive send returned control to its caller. */
+type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
+```
+
+```ts type-equiv
+/** Top-level PTY process status, independent of a send's wait reason. */
+type PtySessionStatus =
+ | { kind: 'running' }
+ | { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
+```
+
+## 后端与活跃会话
+
+后端负责某个已注册类型的启动方式和就绪检测。`PtyService` 只在初始化成功后才发布返回的会话,随后负责 id 授权与清理。无法清理部分启动资源的后端会以 `PtyBackendCleanupError` 拒绝,从而让资源释放流程保留该清理失败,同时不替换调用方的取消原因。后端会话拥有终端状态,并负责使已捕获资源完全停稳。
+
+```ts type-equiv
+/** Replaceable provider for one PTY session type. */
+interface PtyBackend {
+ /** Stable type selected by {@link PtySpawnRequest.type}. */
+ readonly type: string
+ /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */
+ spawn(spec: PtyBackendSpawnSpec): Promise
+}
+```
+
+```ts type-equiv
+/** Backend-owned live session retained by {@link PtyService}. */
+interface PtyBackendSession {
+ /** Initial bounded terminal output returned from `terminal_open`. */
+ readonly motd: string
+ /** Top-level process id when one exists. */
+ readonly pid?: number
+ /** Start one exclusive send operation. */
+ startSend(request: PtySendRequest): PtySendOperation
+ /** Read one bounded page from retained scrollback. */
+ read(request: PtyReadRequest): PtyReadResult
+ /** Signal the verified foreground process group. */
+ signal(signal: PtySignal): Promise
+ /** Observe top-level process status. */
+ status(): PtySessionStatus
+ /** Idempotently close the captured owned process tree and await quiescence. */
+ close(reason: string): Promise
+}
+```
+
+## 发送与保留输出
+
+一个活跃会话同时只接受一个活动发送。该操作向通用后台任务公开一个消费式输出游标,并向前台调用方公开一个最终结果。`PtyReadResult` 则为有界的会话 scrollback 单独分页。
+
+```ts type-equiv
+/** Live backend-owned send; exactly one may be active per PTY session. */
+interface PtySendOperation {
+ /** Resolves after readiness, timeout, cancellation, or top-level process exit. */
+ done: Promise
+ /** Consume output produced since the prior call. */
+ readOutput(): PtySendRead
+ /** Request `SIGINT`; returns false after the operation settled. */
+ cancel(): boolean
+}
+```
+
+```ts type-equiv
+/** Settled result for one foreground or background send. */
+interface PtySendResult {
+ /** Bounded rendered terminal delta remaining at settlement. */
+ viewport: string
+ /** Why the wait returned; this does not imply arbitrary child-process exit. */
+ waitReason: PtyWaitReason
+ /** Top-level session status observed at settlement. */
+ sessionStatus: PtySessionStatus
+ /** Whether output was dropped from the operation or retained scrollback. */
+ truncated: boolean
+}
+```
+
+## 归属与持久性
+
+`PtyService` 会将一项等待完成的清理附加到确切的拥有者作用域,拒绝其他拥有者的操作,并让会话在后端或工具插件重载期间保持存活。PTY 状态与原始字节仍局限在进程内。模型输入与有界的返回输出通过现有 `tool/call`、`tool/result` 和任务结果路径持久保存,而不是重复记录 PTY 会话事件。
diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml
index 2cba0f9b42..f9c7355148 100644
--- a/docs/core-data-structures/session-query.i18n.yaml
+++ b/docs/core-data-structures/session-query.i18n.yaml
@@ -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
-session-query.md: c6dde8714a0875d46cf7b49cc181daab8f44afe0
-session-query.zh.md: 44be8b7f89e1576e54c8dc3cb60619d6728c6895
+session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e
+session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d
diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md
index c6dde8714a..d92af4bac3 100644
--- a/docs/core-data-structures/session-query.md
+++ b/docs/core-data-structures/session-query.md
@@ -51,6 +51,39 @@ interface SessionSurfaceSnapshot {
}
```
+`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. Batch reads return one ordered `SessionTitleObservationResult` per unique requested id: operational failures remain local to that id, while cancellation rejects the complete operation.
+
+```ts type-equiv
+/** Latest folded title bound to the same session-header observation. */
+interface SessionTitleObservation {
+ /** Cloned header selected with the event log used for the title fold. */
+ session: SessionHeader
+ /** Latest title snapshot, absent when the observed log has no title. */
+ title?: SessionTitleSnapshot
+}
+```
+
+```ts type-equiv
+/** One ordered result from a batch title observation. */
+type SessionTitleObservationResult =
+ | {
+ /** Requested session id. */
+ sessionId: SessionId
+ /** Successful atomic header/title observation. */
+ status: 'fulfilled'
+ /** Header and optional latest title from one logical source. */
+ value: SessionTitleObservation
+ }
+ | {
+ /** Requested session id. */
+ sessionId: SessionId
+ /** Operational failure isolated to this session. */
+ status: 'rejected'
+ /** Original failure from logical-source resolution or title folding. */
+ reason: unknown
+ }
+```
+
```ts type-equiv
/** Lightweight metadata for one event within a logical session. */
interface SessionEventRecord {
@@ -158,6 +191,16 @@ interface SessionSearchPage {
}
```
+Unlike grouped cross-session hits, a within-session search must also expose its observed target header even when the page contains no hits.
+
+```ts type-equiv
+/** Event-search results bound to the indexed target-session observation. */
+interface SessionEventSearchPage extends SessionSearchPage {
+ /** Cloned target header from the same indexed generation as `items`. */
+ session: SessionHeader
+}
+```
+
```ts type-equiv
/** One event full-text search hit with a bounded plain-text excerpt. */
interface SessionEventSearchHit extends SessionEventRecord {
@@ -279,6 +322,14 @@ interface SessionEventTrace {
}
```
+```ts type-equiv
+/** Event relationships bound to the same session-header observation. */
+interface SessionEventTraceObservation extends SessionEventTrace {
+ /** Cloned header selected with the event log used for the trace. */
+ session: SessionHeader
+}
+```
+
## Errors
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata.
diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md
index 44be8b7f89..ecf330b0a3 100644
--- a/docs/core-data-structures/session-query.zh.md
+++ b/docs/core-data-structures/session-query.zh.md
@@ -51,6 +51,39 @@ interface SessionSurfaceSnapshot {
}
```
+`SessionTitleObservation` 将同样的原子观测规则应用于标题折叠,使授权消费者能够验证提供标题的源 header。批量读取会按顺序为每个唯一请求 id 返回一个 `SessionTitleObservationResult`:操作失败只影响对应 id,而取消会拒绝整个操作。
+
+```ts type-equiv
+/** Latest folded title bound to the same session-header observation. */
+interface SessionTitleObservation {
+ /** Cloned header selected with the event log used for the title fold. */
+ session: SessionHeader
+ /** Latest title snapshot, absent when the observed log has no title. */
+ title?: SessionTitleSnapshot
+}
+```
+
+```ts type-equiv
+/** One ordered result from a batch title observation. */
+type SessionTitleObservationResult =
+ | {
+ /** Requested session id. */
+ sessionId: SessionId
+ /** Successful atomic header/title observation. */
+ status: 'fulfilled'
+ /** Header and optional latest title from one logical source. */
+ value: SessionTitleObservation
+ }
+ | {
+ /** Requested session id. */
+ sessionId: SessionId
+ /** Operational failure isolated to this session. */
+ status: 'rejected'
+ /** Original failure from logical-source resolution or title folding. */
+ reason: unknown
+ }
+```
+
```ts type-equiv
/** Lightweight metadata for one event within a logical session. */
interface SessionEventRecord {
@@ -158,6 +191,16 @@ interface SessionSearchPage {
}
```
+与跨会话分组 hit 不同,会话内搜索即使没有命中项,也必须公开它观测到的目标 header。
+
+```ts type-equiv
+/** Event-search results bound to the indexed target-session observation. */
+interface SessionEventSearchPage extends SessionSearchPage {
+ /** Cloned target header from the same indexed generation as `items`. */
+ session: SessionHeader
+}
+```
+
```ts type-equiv
/** One event full-text search hit with a bounded plain-text excerpt. */
interface SessionEventSearchHit extends SessionEventRecord {
@@ -279,6 +322,14 @@ interface SessionEventTrace {
}
```
+```ts type-equiv
+/** Event relationships bound to the same session-header observation. */
+interface SessionEventTraceObservation extends SessionEventTrace {
+ /** Cloned header selected with the event log used for the trace. */
+ session: SessionHeader
+}
+```
+
## 错误
封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。
diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml
new file mode 100644
index 0000000000..2fd211e4e2
--- /dev/null
+++ b/docs/core-data-structures/session-reference.i18n.yaml
@@ -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
+session-reference.md: 4898cdd641427a023dde63bfc9759300964c7fac
+session-reference.zh.md: 8e36d70241f2565a587bd3c1ee270d99dba47d71
diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md
index d9a73c08c6..4898cdd641 100644
--- a/docs/core-data-structures/session-reference.md
+++ b/docs/core-data-structures/session-reference.md
@@ -1,5 +1,7 @@
# Session References
+English | [中文](session-reference.zh.md)
+
Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core.
Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts)
diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md
new file mode 100644
index 0000000000..8e36d70241
--- /dev/null
+++ b/docs/core-data-structures/session-reference.zh.md
@@ -0,0 +1,67 @@
+# 会话引用
+
+[English](session-reference.md) | 中文
+
+结构化的跨会话引用请求与预备消息上下文。[包(package)契约](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。
+
+来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts)
+
+## 输入与候选项
+
+`SessionReferenceInput` 是与宿主无关的选择。id 具有权威性;label 是随快照携带的显示元数据。
+
+```ts type-equiv
+/** One source session selected by a host. */
+interface SessionReferenceInput {
+ /** Opaque source session identity. */
+ sessionId: SessionId
+ /** Optional user-facing mention label. */
+ label?: string
+}
+```
+
+`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选仍只搜索 session id 和 cwd,绝不搜索 transcript(文本记录)。
+
+```ts type-equiv
+/** One host-facing candidate from exact session metadata. */
+interface SessionReferenceCandidate {
+ /** Opaque source session identity. */
+ sessionId: SessionId
+ /** Latest log-backed title, falling back to the opaque session id. */
+ label: string
+ /** Source session working directory, when recorded. */
+ cwd?: string
+ /** Source session creation time in Unix epoch milliseconds. */
+ createdAt: number
+}
+```
+
+## 预备消息
+
+预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。宿主会把 `contexts` 绑定到该次确切的 `followup()` 或 `steer()` 调用。
+
+```ts type-equiv
+/** Message payload and the zero-or-one durable snapshot contexts bound to it. */
+interface PreparedReferencedMessage {
+ /** Readable message content after host mention tokens are removed. */
+ content: ContentBlock[]
+ /** Empty without references; otherwise one aggregated untrusted context. */
+ contexts: HookContext[]
+}
+```
+
+## 错误
+
+`SessionReferenceError.code` 区分无效配置或输入、自引用、数量限制、源读取失败、预算失败和取消。宿主协议会把这些 code 映射到各自的错误信封,无需检查提示词字节。
+
+```ts type-equiv
+/** Stable failure codes exposed to host adapters. */
+type SessionReferenceErrorCode =
+ | 'SESSION_REFERENCE_INVALID_CONFIG'
+ | 'SESSION_REFERENCE_INVALID_REFERENCE'
+ | 'SESSION_REFERENCE_SELF_REFERENCE'
+ | 'SESSION_REFERENCE_TOO_MANY'
+ | 'SESSION_REFERENCE_READ_FAILED'
+ | 'SESSION_REFERENCE_BUDGET_EXCEEDED'
+ | 'SESSION_REFERENCE_CANCELLED'
+```
diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml
new file mode 100644
index 0000000000..f31d9016ac
--- /dev/null
+++ b/docs/core-data-structures/session-title.i18n.yaml
@@ -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
+session-title.md: 6575bda5fdecf2be15ed7c3288efb0efa759ac8a
+session-title.zh.md: 66ec567ac6cef32acef9c50ae3e67155097a76b6
diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md
index 39e2b00ae5..6575bda5fd 100644
--- a/docs/core-data-structures/session-title.md
+++ b/docs/core-data-structures/session-title.md
@@ -1,5 +1,7 @@
# Session Titles
+English | [中文](session-title.zh.md)
+
Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations.
Sources: [`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts), [`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts)
diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md
new file mode 100644
index 0000000000..66ec567ac6
--- /dev/null
+++ b/docs/core-data-structures/session-title.zh.md
@@ -0,0 +1,142 @@
+# 会话标题
+
+[English](session-title.md) | 中文
+
+[`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title) 所拥有的持久化后写覆盖标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包(package)README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.md)负责完整的事件声明。
+
+源码:[`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts)、[`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts)
+
+## 持久标题状态
+
+提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 携带精确的人类消息来源信息,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件信封事实。
+
+```ts type-equiv
+/** Identifies one session-title provider registration. */
+type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
+```
+
+```ts type-equiv
+/** Exact auxiliary model route that produced a title. */
+interface SessionTitleModelProvenance {
+ /** Registered LLM provider route. */
+ readonly provider: string
+ /** Provider model id. */
+ readonly model: string
+}
+```
+
+```ts type-equiv
+/** Durable ownership record for an accepted session title. */
+type SessionTitleSource =
+ | { readonly kind: 'fallback' }
+ | {
+ readonly kind: 'provider'
+ readonly provider: SessionTitleProviderId
+ readonly model?: SessionTitleModelProvenance
+ }
+```
+
+```ts type-equiv
+/** Payload of the log-only `session/title` event. */
+interface SessionTitleEventData {
+ /** Normalized non-empty title text. */
+ readonly title: string
+ /** Exact human `user/message` seqs used to derive this title. */
+ readonly messageSeqs: number[]
+ /** Built-in fallback or registered-provider provenance. */
+ readonly source: SessionTitleSource
+}
+```
+
+```ts type-equiv
+/** Latest folded title plus the title event's durable envelope facts. */
+interface SessionTitleSnapshot extends SessionTitleEventData {
+ /** Seq of the latest `session/title` event. */
+ readonly eventSeq: number
+ /** Timestamp of the latest `session/title` event. */
+ readonly updatedAt: number
+}
+```
+
+## 辅助请求记录
+
+共享 LLM 辅助组件会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、提供方归属和源消息归因。
+
+```ts type-equiv
+/** Exact model-visible request recorded before one auxiliary title dispatch. */
+interface SessionTitleLlmRequestEventData {
+ /** Registered title-provider identity responsible for the request. */
+ readonly titleProvider: SessionTitleProviderId
+ /** Exact human `user/message` seqs represented in `messages`. */
+ readonly messageSeqs: number[]
+ /** Exact auxiliary LLM route. */
+ readonly route: SessionTitleModelProvenance
+ /** Exact auxiliary system prompt. */
+ readonly system: string
+ /** Exact auxiliary message list. */
+ readonly messages: Message[]
+ /** Exact auxiliary output-token cap. */
+ readonly maxTokens: number
+}
+```
+
+## 提供方输入与输出
+
+服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接受过程会验证顺序、规范化标题、强制执行字节上限并追加来源信息。
+
+```ts type-equiv
+/** One eligible human text message exposed to title providers. */
+interface SessionTitleUserMessage {
+ /** Source `user/message` event seq. */
+ readonly seq: number
+ /** Exact concatenated text-block content. */
+ readonly text: string
+}
+```
+
+```ts type-equiv
+/** Automatic generation cadence owned by a registered provider. */
+type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
+```
+
+```ts type-equiv
+/** Immutable input supplied to one title-provider call. */
+interface SessionTitleProviderRequest {
+ /** Live session being titled. */
+ readonly session: Session
+ /** All eligible human messages through this generation revision. */
+ readonly messages: readonly SessionTitleUserMessage[]
+ /** Exact current logged main-request route, when one has been recorded. */
+ readonly route?: SessionTitleModelProvenance
+ /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
+ readonly signal: AbortSignal
+}
+```
+
+```ts type-equiv
+/** Provider output before service-owned normalization and durable acceptance. */
+interface SessionTitleProviderResult {
+ /** Proposed title text. */
+ readonly title: string
+ /** Exact seqs from `request.messages` used by this result. */
+ readonly messageSeqs: readonly number[]
+ /** Auxiliary LLM route, when generation used a model. */
+ readonly model?: SessionTitleModelProvenance
+}
+```
+
+```ts type-equiv
+/** One optional asynchronous title implementation registered with the service. */
+interface SessionTitleProvider {
+ /** Stable provider identity recorded in title provenance. */
+ readonly id: SessionTitleProviderId
+ /** When new human prompts start automatic generation. */
+ readonly automatic: SessionTitleAutomaticMode
+ /**
+ * Produce one title revision.
+ * @param request - message snapshot, current route, session, and cancellation.
+ * @returns proposed title plus exact input seqs and optional model provenance.
+ */
+ generate(request: SessionTitleProviderRequest): Promise
+}
+```
diff --git a/docs/core-data-structures/spill.i18n.yaml b/docs/core-data-structures/spill.i18n.yaml
new file mode 100644
index 0000000000..4cd4cc5e1e
--- /dev/null
+++ b/docs/core-data-structures/spill.i18n.yaml
@@ -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
+spill.md: a798d8143b2849dc0cf49d04e7019ce796cdee45
+spill.zh.md: 1af6939d1d8fd37958cae4f9cf2cbf706b17acd0
diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md
index a964064912..a798d8143b 100644
--- a/docs/core-data-structures/spill.md
+++ b/docs/core-data-structures/spill.md
@@ -1,5 +1,7 @@
# Spill Storage
+English | [中文](spill.zh.md)
+
The spill storage seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it.
Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts)
diff --git a/docs/core-data-structures/spill.zh.md b/docs/core-data-structures/spill.zh.md
new file mode 100644
index 0000000000..1af6939d1d
--- /dev/null
+++ b/docs/core-data-structures/spill.zh.md
@@ -0,0 +1,85 @@
+# 落盘存储
+
+[English](spill.md) | 中文
+
+落盘存储 seam 是一项[能力 seam](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md),它持久保存工具的超大文本,并返回面向模型的定位符与检索指引;该能力拆分到三个包(package):接口([dsh-spill](../../packages/spill/spill),`ctx.spillStore`)、实现([dsh-spill-local](../../packages/spill/spill-local),宿主文件系统中会话作用域的私有文件)和消费方([dsh-spill-policy](../../packages/spill/spill-policy),`tools/post-execute` 策略)。落盘是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇记录在此处,而不在 [core.md](core.md) 中。预览机制仍归 [dsh-retention](../../packages/util/retention) 所有;该 seam 只保存策略交给它的最终文本。
+
+源码:[`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts)
+
+## 保存请求
+
+`saveText` 是整个 seam:原样持久保存 `content`,并返回不透明的定位符、后端提供的检索提示和准确字节数。请求携带保存时的存储命名空间(`owner`)、内容来源(`source`,用于命名和检查的描述性来源信息,而非访问控制)以及后端可用作命名提示的 `suggestedName`(它不是路径)。
+
+```ts type-equiv
+/** One request to persist text to a spill artifact. */
+interface SaveTextSpill {
+ owner: SpillOwner
+ source: SpillSource
+ /**
+ * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes
+ * it to a single safe path segment before use — it is a hint, never a path.
+ */
+ suggestedName: string
+ /** The full text to persist (UTF-8). */
+ content: string
+}
+```
+
+```ts type-equiv
+/**
+ * Save-time storage namespace for a spilled artifact. The session id lets a
+ * backend group storage under the producing session, but the returned
+ * {@link SpillLocator} is the model-facing handle. Forked sessions inherit
+ * locators already present in the seeded log; those artifacts are not copied or
+ * re-owned, and spills produced after the fork use the child session id.
+ */
+interface SpillOwner {
+ sessionId: SessionId
+}
+```
+
+`SpillOwner.sessionId` 是保存时的存储命名空间。fork 后的会话会从种子日志继承已有的落盘定位符;这些产物不会被复制或重新取得所有权,fork 后产生的落盘则使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。
+
+```ts type-equiv
+/**
+ * Provenance of one spilled artifact — recorded by the backend for a readable
+ * filename and inspection. Not interpreted for access control; purely
+ * descriptive.
+ */
+interface SpillSource {
+ /** The tool whose result was spilled (e.g. `web_fetch`). */
+ toolName: string
+ /** The model-issued call id the result belongs to. */
+ callId: CallId
+ /** A short human label for the artifact (e.g. `result`). */
+ label: string
+}
+```
+
+## 结果
+
+```ts type-equiv
+/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */
+interface SpillRef {
+ locator: SpillLocator
+ bytes: number
+ retrievalHint: string
+}
+```
+
+`SpillLocator` 是后端返回的[品牌化](core.md#branded-ids)面向模型句柄。本地后端将它渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方将它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。
+
+```ts type-equiv
+/**
+ * Opaque model-facing handle for one spilled artifact. A local backend may use a
+ * filesystem path; a remote or database backend may use a URI or key. Consumers
+ * render it with {@link SpillRef.retrievalHint}, but do not parse it.
+ */
+type SpillLocator = Branded<'SpillLocator'>
+```
+
+## 服务
+
+`SpillStore`(`ctx.spillStore`,定义于 [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts))是只有一个方法的抽象服务:`saveText(input) → Promise`。它持久保存完整的 `content`,并在实际存储失败(权限、ENOSPC、后端不可用)时拒绝。该 seam 只负责存储:不负责保留策略、工具结果替换或检索/搜索 API。
+
+本地后端([dsh-spill-local](../../packages/spill/spill-local))写入 `/session-/-`:根目录是已配置或延迟创建的私有(0700)目录,会话子目录采用 `sha256(sessionId)`,并通过排他的仅所有者可访问写入(`open(path, 'wx', 0o600)`)防止预先植入的符号链接重定向写入。其 `locator` 是本地路径,`retrievalHint` 则告知模型在该路径上使用 `read` 或 `grep`。策略消费方([dsh-spill-policy](../../packages/spill/spill-policy))会把超过 `maxInlineBytes` 的纯文本最终结果替换为保留库生成的首尾预览和落盘引用;该过程尽力而为:保存失败时保留原始内联结果,而不会把成功的调用变成 `isError`。
diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml
index d8d6a493d7..af4b1decda 100644
--- a/docs/core-data-structures/subagent.i18n.yaml
+++ b/docs/core-data-structures/subagent.i18n.yaml
@@ -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
-subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073
-subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b
+subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff
+subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2
diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md
index 0335a3f078..2497dbab9c 100644
--- a/docs/core-data-structures/subagent.md
+++ b/docs/core-data-structures/subagent.md
@@ -19,16 +19,13 @@ A provider advertises its **start-time** features on a static descriptor the ser
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
- * is the capability.
+ * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
+ * `depthLimit` to `maxDepth`; the other names match.
*/
interface SubagentCapabilities {
- /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean
- /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean
- /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean
- /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean
}
```
@@ -45,16 +42,12 @@ The tool layer builds this request from the model input and its own config; the
* passes it to {@link SubagentProvider.start}.
*/
interface SubagentStartRequest {
- /** The task/prompt for the child agent (a user message in the child session). */
+ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
- * The spawning ("parent") agent — the one whose tool call started this
- * subagent. REQUIRED: in-process backends read `parent.session.header` for
- * the working directory, the `parentSession` lineage to stamp on the child,
- * and the parent's delegation depth. The out-of-process backend (ACP) reads
- * exactly one field — the session header's cwd, the child's workspace when
- * no deployment `cwd` override is configured; nothing else crosses the
- * process boundary.
+ * The spawning agent. In-process providers derive workspace, lineage, and
+ * delegation depth from its durable session state. ACP reads only its cwd,
+ * and only when no deployment `cwd` override is configured.
*/
readonly parent: Agent
/**
@@ -65,7 +58,6 @@ interface SubagentStartRequest {
* afterward.
*/
readonly signal: AbortSignal
- /** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -137,9 +129,9 @@ interface SubagentResult {
interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
- /** The run was cancelled by its request signal or by disposal. */
+ /** Cancelled through the request signal or disposal. */
aborted: 'aborted'
- /** The child failed (model error, transport error). */
+ /** Model or transport failure. */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
@@ -180,9 +172,8 @@ interface SubagentRun {
*/
readonly result: Promise
/**
- * Cancel remaining work, reach child quiescence, and release the run's
- * resources (in-process: dispose the owned agent and remove its session;
- * ACP: kill and reap the subprocess). Idempotent.
+ * Cancel remaining work, reach child quiescence, and release resources.
+ * Idempotent.
*/
dispose(): Promise
/**
@@ -206,12 +197,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi
```ts type-equiv
/**
- * A subagent backend: one transport for running a child agent (in-process
- * spawn/fork, ACP to another process, …). Implementations register under a
- * unique name via {@link SubagentService.registerProvider}; multiple providers
- * coexist in one context (unlike the single-implementation bash seam). The
- * Providers are trusted same-process implementations; callers treat their
- * descriptors and returned values as borrowed immutable data.
+ * One registered transport for running child agents. Providers are trusted
+ * same-process implementations; callers treat descriptors and returned values
+ * as borrowed immutable data.
*/
interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md
index dac48b624f..2d96e9bc63 100644
--- a/docs/core-data-structures/subagent.zh.md
+++ b/docs/core-data-structures/subagent.zh.md
@@ -19,16 +19,13 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
- * is the capability.
+ * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
+ * `depthLimit` to `maxDepth`; the other names match.
*/
interface SubagentCapabilities {
- /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean
- /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean
- /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean
- /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean
}
```
@@ -45,16 +42,12 @@ interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}.
*/
interface SubagentStartRequest {
- /** The task/prompt for the child agent (a user message in the child session). */
+ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
- * The spawning ("parent") agent — the one whose tool call started this
- * subagent. REQUIRED: in-process backends read `parent.session.header` for
- * the working directory, the `parentSession` lineage to stamp on the child,
- * and the parent's delegation depth. The out-of-process backend (ACP) reads
- * exactly one field — the session header's cwd, the child's workspace when
- * no deployment `cwd` override is configured; nothing else crosses the
- * process boundary.
+ * The spawning agent. In-process providers derive workspace, lineage, and
+ * delegation depth from its durable session state. ACP reads only its cwd,
+ * and only when no deployment `cwd` override is configured.
*/
readonly parent: Agent
/**
@@ -65,7 +58,6 @@ interface SubagentStartRequest {
* afterward.
*/
readonly signal: AbortSignal
- /** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -137,9 +129,9 @@ interface SubagentResult {
interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
- /** The run was cancelled by its request signal or by disposal. */
+ /** Cancelled through the request signal or disposal. */
aborted: 'aborted'
- /** The child failed (model error, transport error). */
+ /** Model or transport failure. */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
@@ -182,9 +174,8 @@ interface SubagentRun {
*/
readonly result: Promise
/**
- * Cancel remaining work, reach child quiescence, and release the run's
- * resources (in-process: dispose the owned agent and remove its session;
- * ACP: kill and reap the subprocess). Idempotent.
+ * Cancel remaining work, reach child quiescence, and release resources.
+ * Idempotent.
*/
dispose(): Promise
/**
@@ -208,12 +199,9 @@ interface SubagentRun {
```ts type-equiv
/**
- * A subagent backend: one transport for running a child agent (in-process
- * spawn/fork, ACP to another process, …). Implementations register under a
- * unique name via {@link SubagentService.registerProvider}; multiple providers
- * coexist in one context (unlike the single-implementation bash seam). The
- * Providers are trusted same-process implementations; callers treat their
- * descriptors and returned values as borrowed immutable data.
+ * One registered transport for running child agents. Providers are trusted
+ * same-process implementations; callers treat descriptors and returned values
+ * as borrowed immutable data.
*/
interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml
new file mode 100644
index 0000000000..f9d14f2163
--- /dev/null
+++ b/docs/core-data-structures/tasks.i18n.yaml
@@ -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
+tasks.md: d1f5a6d7b369e6113132f60e493cf87757e20599
+tasks.zh.md: 1562d9401f0f55ac6d6260902b8b1c71d9664d48
diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md
index 2c7555b84d..d1f5a6d7b3 100644
--- a/docs/core-data-structures/tasks.md
+++ b/docs/core-data-structures/tasks.md
@@ -1,5 +1,7 @@
# Background Task Runtime
+English | [中文](tasks.zh.md)
+
Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts).
## Ids and status
diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md
new file mode 100644
index 0000000000..1562d9401f
--- /dev/null
+++ b/docs/core-data-structures/tasks.zh.md
@@ -0,0 +1,154 @@
+# 后台任务运行时
+
+[English](tasks.md) | 中文
+
+长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。
+
+## ID 与状态
+
+`TaskId` 是按 `