Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into fix/permission-copy-and-default

# Conflicts:
#	packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
Kaige-Gao 2 luni în urmă
părinte
comite
8304a25d8a
100 a modificat fișierele cu 1191 adăugiri și 204 ștergeri
  1. 3 0
      .agents/notes/archived/manifest.json
  2. 2 2
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml
  3. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md
  4. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md
  5. 2 2
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
  6. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
  7. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
  9. 4 0
      .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
  10. 4 0
      .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
  12. 1 1
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
  13. 1 1
      .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
  14. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  15. 3 1
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  16. 3 1
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
  18. 1 1
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
  19. 1 1
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml
  21. 1 1
      .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
  22. 1 1
      .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md
  23. 2 2
      .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml
  24. 2 2
      .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
  25. 2 2
      .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md
  26. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
  27. 1 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
  28. 1 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
  29. 6 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
  30. 39 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
  31. 39 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
  32. 2 2
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml
  33. 10 12
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
  34. 10 12
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md
  35. 6 0
      .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml
  36. 65 0
      .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md
  37. 65 0
      .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md
  38. 6 0
      .agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml
  39. 37 0
      .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
  40. 37 0
      .agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md
  41. 6 0
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml
  42. 81 0
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
  43. 81 0
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md
  44. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml
  45. 33 0
      .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md
  46. 33 0
      .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md
  47. 6 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml
  48. 43 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md
  49. 43 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md
  50. 6 0
      .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml
  51. 27 0
      .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md
  52. 27 0
      .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md
  53. 2 2
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml
  54. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md
  55. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md
  56. 6 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml
  57. 33 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md
  58. 33 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md
  59. 2 2
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
  60. 1 1
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
  61. 1 1
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
  62. 2 2
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml
  63. 16 14
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
  64. 16 14
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md
  65. 2 2
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
  66. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
  67. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
  68. 6 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml
  69. 49 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md
  70. 49 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md
  71. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  72. 1 1
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  73. 1 1
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  74. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml
  75. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
  76. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md
  77. 2 2
      .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
  78. 17 7
      .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
  79. 17 7
      .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
  80. 2 2
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml
  81. 5 5
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
  82. 5 5
      .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
  83. 2 2
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml
  84. 15 15
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
  85. 15 15
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md
  86. 2 2
      .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml
  87. 1 1
      .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md
  88. 1 1
      .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md
  89. 2 2
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml
  90. 1 1
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
  91. 1 1
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md
  92. 6 0
      .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml
  93. 35 0
      .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md
  94. 35 0
      .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md
  95. 2 2
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml
  96. 5 3
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md
  97. 5 3
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md
  98. 2 2
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml
  99. 4 4
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
  100. 4 4
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -334,6 +334,9 @@
     "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee",
     "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec",
     "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1",
+    "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a",
+    "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b",
+    "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976",
     "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db",
     "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67",
     "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c",

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.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 .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md
-2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0
-2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9
+2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6
+2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md

@@ -1,6 +1,7 @@
 # Agent Note: Drop the `image` content block until a path can honor it
 
 Status: implemented
+Archived: 2026-08-19
 
 English | [中文](2026-07-04-drop-image-content-block.zh.md)
 

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它
 
 Status: implemented
+Archived: 2026-08-19
 
 [English](2026-07-04-drop-image-content-block.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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 .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
-2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022
-2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600
+2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b
+2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md

@@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall
 ## Consequences
 
 - Reasoning has a core home without provider-specific shapes.
-- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md).
+- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md).
 - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes.
 - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
 - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost.

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md

@@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
 ## 后果
 
 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。
-- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。
+- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。
 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。
 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。

+ 2 - 2
.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 .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
-2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e
-2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30
+2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd
+2026-06-18-shared-persistence-write-coordinator.zh.md: d24398c4bc9445739b4cdeb9f2ed176577060932

+ 4 - 0
.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md

@@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho
 
 The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence.
 
+Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary.
+
+Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing.
+
 The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend.
 
 ### The hook interface (`PersistenceBackend<TornMarker>`)

+ 4 - 0
.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md

@@ -16,6 +16,10 @@ Status: implemented
 
 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。
 
+创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。
+
+已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。
+
 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。
 
 ### 钩子接口(`PersistenceBackend<TornMarker>`)

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.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 .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
-2026-07-07-tool-call-timeout-policy.md: ce414e541f8e374dd48e46d68cb00121e0004247
-2026-07-07-tool-call-timeout-policy.zh.md: 6fe3c979a3c4e7b7a6ed803a47af45ad32d52cce
+2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8
+2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md

@@ -77,7 +77,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
 
 ### Existing tool adaptation
 
-`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
+`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` has no `timeout_ms` parameter, while `web_search` accepts a required `queries` array without a timeout argument. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
 
 `dsh-web-fetch-http` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md

@@ -77,7 +77,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
 
 ### 现有工具适配
 
-`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent(智能体)的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
+`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 没有 `timeout_ms` 参数,`web_search` 接受必填的 `queries` 数组,但不接受超时参数。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
 
 `dsh-web-fetch-http` 保留一个在提供方层面配置的 `timeoutMs`,作为较大的资源兜底值,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: fa2f86893b730aa1ba020bd568d268ec8d9d6239
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 509bec18edb9923dd4d60d4ecf30d4fbcd9cc6d5
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd

Fișier diff suprimat deoarece este prea mare
+ 3 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Fișier diff suprimat deoarece este prea mare
+ 3 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
-2026-07-30-client-locale-full-rollout.md: c6c5a8f2faffd3e03462eaad159ae94c53c735ce
-2026-07-30-client-locale-full-rollout.zh.md: 8d6220784104944f5d07b533e4f107ceffdfcfea
+2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab
+2026-07-30-client-locale-full-rollout.zh.md: 0c05ec9699700d88d786bc661f7013f2ed09ebb2

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md

@@ -42,4 +42,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
 - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
 - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
 - ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo.
-- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy.
+- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md

@@ -42,4 +42,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
 - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。
 - ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。
-- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。
+- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
-2026-07-30-settings-write-path-integrity.md: c01f04a9b88417115505a8fc9fd3641055e95472
-2026-07-30-settings-write-path-integrity.zh.md: 967acf3266451e5a3974b37bc5703e5d45592007
+2026-07-30-settings-write-path-integrity.md: 7a2d377586ff2bfa7caeb9d4196ee99f70d3e63f
+2026-07-30-settings-write-path-integrity.zh.md: fa68bfba04382d6cafd03bf174ebadcd6bafd519

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md

@@ -14,7 +14,7 @@ The provider's write path could destroy state it never observed, and the Service
 
 **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
 
-**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
+**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. `EEXIST` identifies contention directly; `EPERM` identifies it only when `lstat` confirms that the lock path exists, because Windows may report permission denial for an exclusive create against that existing path. An unrelated permission failure remains loud. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
 
 **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md

@@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
 
 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其「告警并保留最后可用值」策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
 
-**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。
+**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。`EEXIST` 直接表示竞争;只有 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,因为 Windows 可能把针对该现有路径的独占创建报告为权限拒绝。无关的权限故障仍会响亮失败。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。
 
 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件约定现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
-2026-08-04-configuration-source-ownership.md: c90979ff3ade89c11af7fb9a73d536aa2c1daa12
-2026-08-04-configuration-source-ownership.zh.md: 31a813eddf4aefaf4bce1fd9999660abc8032c29
+2026-08-04-configuration-source-ownership.md: 2cd09ae2daca2b15657caa18ff210fa178c2999b
+2026-08-04-configuration-source-ownership.zh.md: 76625cade4b1034c2327344945f80e3a4fec5bdc

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md

@@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret,
 
 **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today.
 
-**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
+**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
 
 The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
 
@@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside
 ## Consequences
 
 - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
-- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
+- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
 - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
 - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
 - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md

@@ -43,7 +43,7 @@ inherited process environment      (read-only, wins)
 
 **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。
 
-**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
+**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
 
 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
 
@@ -54,7 +54,7 @@ inherited process environment      (read-only, wins)
 ## Consequences
 
 - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
-- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
+- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。
 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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 .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
-2026-08-05-profile-plugin-bundles.md: 94e3ddad0cfea0a1aaf2efe8d15f26f471bb2bd3
-2026-08-05-profile-plugin-bundles.zh.md: 96d22c57f68ecc72e1afd34fbbd3cfbe3ac24c61
+2026-08-05-profile-plugin-bundles.md: c9f685eecddcd4ea8d8580becef3c32a9693a329
+2026-08-05-profile-plugin-bundles.zh.md: 43680862a13171071e236d56c336b790bbe50432

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md

@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
 
 Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md).
 
-The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
+The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
 
 Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
 
-随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag ,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
+默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
 
 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
+2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0
+2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c

+ 39 - 0
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md

@@ -0,0 +1,39 @@
+# Agent Note: Cancelled streams finalize their delivered prefix
+
+Status: implemented
+
+English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md)
+
+## Problem
+
+A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
+
+The model history must contain assistant content that remains visible to the user after cancellation.
+
+## Decision
+
+`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
+
+`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
+
+Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
+
+## Alternatives considered
+
+**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user.
+
+**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
+
+**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result.
+
+**Append a model-visible interruption message such as `[interrupted by user]`.** This can tell the model that the prefix is incomplete, but it requires a separate source type, projection rule, UI treatment, and localized wording. The durable aborted `turn/end` preserves the fact needed for that later decision.
+
+## Consequences
+
+Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason.
+
+Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy.
+
+## Testing
+
+`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.

+ 39 - 0
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 被取消的流定稿其已送达前缀
+
+Status: implemented
+
+[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文
+
+## Problem
+
+被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
+
+模型历史必须包含取消后仍对用户可见的 assistant 内容。
+
+## Decision
+
+`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
+
+`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。
+
+Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
+
+## Alternatives considered
+
+**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
+
+**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。
+
+**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
+
+**追加 `[interrupted by user]` 之类模型可见的打断消息。** 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted `turn/end` 保留了该后续决策所需的事实。
+
+## Consequences
+
+取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。
+
+终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。
+
+## Testing
+
+`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
-2026-08-10-product-subagent-providers-in-shared-host.md: 452ff1cca7e4e5f91f8c35092761ebe83f3ff174
-2026-08-10-product-subagent-providers-in-shared-host.zh.md: a62bf6faa3c9bba5326da1de20ecbc2946c02bcc
+2026-08-10-product-subagent-providers-in-shared-host.md: 196e28c1263c4b6d71eaeb59b9ba8457b36f3ff4
+2026-08-10-product-subagent-providers-in-shared-host.zh.md: b36398be50065dd1520fb97ca15416467c580bf2

+ 10 - 12
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md

@@ -6,36 +6,34 @@ English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md)
 
 ## Problem
 
-The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself.
+The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique within the Host, and host consumers resolve the same registry across sessions. Repeated preset composition would therefore contend for the same configured names. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself.
 
-The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts.
+The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts.
 
 ## Decision
 
-Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts it once on the host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry.
+Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry.
 
-This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
+Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
 
-The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation.
-
-Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable.
+Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation.
 
 ## Verification
 
-The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only and dual-provider opt-in paths register the selected providers without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence.
+The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence.
 
 ## Alternatives considered
 
-**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that any selected provider is mounted once on the host plane rather than inside the preset.
+**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure but requires the user to edit both a Profile and a Preset. The production-install exclusion decision accepts that installation trade-off; this note retains the requirement that selected provider instances are mounted on the host plane rather than inside the preset.
 
 **Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state.
 
-**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime.
+**Mount providers inside every Agent Preset.** Provider names belong to a process registry, so repeated session composition would collide on the same configured names. Host consumers also need the registry independently of any one agent's lifetime.
 
 **Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state.
 
 ## Consequences
 
-A user installs each selected product provider in a Profile and exposes its tool through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading a selected provider still starts no product process, login, model call, or product home.
+A user installs each selected product provider in a Profile, mounts the required named instances, and exposes their tools through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading selected instances still starts no product process, login, model call, or product home.
 
-The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is a two-layer opt-in: the Profile owns installation and host-plane registration, while the Preset owns per-agent exposure. Selecting the Claude provider also accepts its current SDK optional-payload installation cost.
+The Host registry remains the single provider authority, each Bundle remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring.

+ 10 - 12
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md

@@ -6,36 +6,34 @@ Status: implemented
 
 ## 问题
 
-[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。
+[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称在 Host 内唯一,而宿主消费方会跨会话解析同一个注册表。因此,重复组装 preset 会争用同一组已配置名称。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 配置项本身不完整。
 
-归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。
+归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。
 
 ## 决策
 
-产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。
+产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。
 
-本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
+每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
 
-这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。
-
-只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。
+每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。
 
 ## 验证
 
-base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置行。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 与双提供方按需启用路径会注册选中的提供方,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。
+base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。
 
 ## 考虑过的替代方案
 
-**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方都在 host plane 挂载一次,而不是放入 preset。
+**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但要求用户同时编辑 Profile 与 Preset。生产安装排除决策接受这项安装取舍;本说明保留的要求是,任何被选中的提供方实例都在 host plane 挂载,而不是放入 preset。
 
 **存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。
 
-**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。
+**在每个 Agent Preset 内挂载提供方。** 提供方名称属于进程级注册表,因此重复组装会话会在同一组已配置名称上发生冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。
 
 **交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。
 
 ## 后果
 
-用户在 Profile 中安装每个被选中的产品提供方,再通过与其他插件相同的 Agent Preset 创作路径暴露它的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的提供方仍不会启动产品进程、登录、调用模型或创建产品主目录。
+用户在 Profile 中安装每个被选中的产品提供方,挂载所需命名实例,再通过与其他插件相同的 Agent Preset 创作路径公开这些实例的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的实例仍不会启动产品进程、登录、调用模型或创建产品主目录。
 
-宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是两层按需启用:Profile 负责安装与 host plane 注册,Preset 负责按 agent 暴露。选择 Claude 提供方还会接受当前 SDK 可选载荷的安装成本。
+Host 注册表仍是提供方的唯一权威,每个 Bundle 仍是部署可用性的权威,每个 Preset 仍是模型工具的权威。这个显式的双门生命周期避免全局启用开关,并让包移除与按会话创作保持独立。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-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 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md
+2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602
+2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f

+ 65 - 0
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md

@@ -0,0 +1,65 @@
+# Agent Note: Persistent pwsh over the terminal seam on Windows
+
+Status: implemented
+
+English | [中文](2026-08-11-pwsh-persistent-pty.zh.md)
+
+## Problem
+
+The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work.
+
+The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX.
+
+Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows.
+
+## Decision
+
+A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows.
+
+### Windows substrate in `@deepseek-ai/dsh-subprocess-local`
+
+`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only.
+
+`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend.
+
+### Shell dialect in `@deepseek-ai/dsh-terminal-bash`
+
+One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred).
+
+### `@deepseek-ai/dsh-tool-pwsh-persistent`
+
+A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform.
+
+Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash.
+
+### Composition
+
+The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh.
+
+### Testing
+
+The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result.
+
+## Alternatives considered
+
+- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors.
+- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap.
+- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around.
+- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end.
+- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text.
+- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred.
+- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it.
+
+## Consequences
+
+**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms.
+
+**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`.
+
+**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier.
+
+**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead.
+
+**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks.
+
+**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has.

+ 65 - 0
.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md

@@ -0,0 +1,65 @@
+# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh
+
+Status: implemented
+
+[English](2026-08-11-pwsh-persistent-pty.md) | 中文
+
+## 问题
+
+harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。
+
+这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。
+
+两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。
+
+## 决定
+
+模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。
+
+### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座
+
+`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。
+
+`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。
+
+### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言
+
+一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。
+
+### `@deepseek-ai/dsh-tool-pwsh-persistent`
+
+新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。
+
+命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。
+
+### 组合
+
+minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。
+
+### 测试
+
+Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。
+
+## 备选方案
+
+- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。
+- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。
+- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。
+- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。
+- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。
+- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。
+- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。
+
+## 后果
+
+**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。
+
+**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。
+
+**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。
+
+**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。
+
+**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。
+
+**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-18-client-build-environment.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 .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
+2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431
+2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328

+ 37 - 0
.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md

@@ -0,0 +1,37 @@
+# Agent Note: Build-time public environment variables for client business code
+
+Status: implemented
+
+English | [中文](2026-08-18-client-build-environment.zh.md)
+
+## Problem
+
+Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type.
+
+Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published.
+
+## Decision
+
+`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`.
+
+The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability.
+
+The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it.
+
+The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record.
+
+## Alternatives considered
+
+**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`.
+
+**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable.
+
+**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices.
+
+**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path.
+
+## Consequences
+
+The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption.
+
+Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism.

+ 37 - 0
.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: Client 业务代码使用构建期公开环境变量
+
+Status: implemented
+
+[English](2026-08-18-client-build-environment.md) | 中文
+
+## Problem
+
+浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。
+
+浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。
+
+## Decision
+
+`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。
+
+Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。
+
+`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。
+
+根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。
+
+## Alternatives considered
+
+**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。
+
+**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。
+
+**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。
+
+**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。
+
+## Consequences
+
+Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。
+
+任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.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 .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
+2026-08-18-sqlite-physical-chunk-row-compression.md: e46adf26ab4ce0a495f3509977ab0835631c16a9
+2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 8e3978c1319545fd09dc668fbd46434a03eb8def

+ 81 - 0
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md

@@ -0,0 +1,81 @@
+# Agent Note: SQLite physical chunk-row compression
+
+Status: implemented
+
+English | [中文](2026-08-18-sqlite-physical-chunk-row-compression.zh.md)
+
+## Problem
+
+The scalar [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) layout stores one physical row per logical `SessionEvent`. Provider streams produce token-sized `assistant/chunk` events with repeated turn, step, block, type, and envelope fields, so transaction batching reduces commits without reducing row count or repeated JSON payload. The logical stream cannot be coalesced because chunk boundaries, sequence numbers, timestamps, replay, partial output, UI fidelity, and `sourceEventSeqs` remain observable.
+
+A physical row that represents several events affects append contiguity, crash repair, suffix seeks, schema ownership, revisions, and stale writers. Durable decoding must also be fixed by the schema version; a configurable codec set could make one schema version unreadable under a different Cordis composition.
+
+## Decision
+
+`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-17 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
+
+Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members.
+
+SQLite owns chunk encoding and validation inside the schema-17 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
+
+The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes.
+
+`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 17 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
+
+### Transactional append packing
+
+Each append acquires `BEGIN IMMEDIATE`, rechecks schema ownership, selects the bounded physical span that may cover the last stored sequence, and derives the next logical sequence from that decoded tail. A mismatch rejects a stale writer before mutation. The codec packs only the new durable batch. Its inserts, lazy session materialization, and one revision increment commit or roll back together.
+
+Normal append never deletes or replaces an earlier event row. Fixed write-behind windows normally collect high-frequency deltas into useful runs, while sparse or explicitly flushed batches may remain scalar. This makes physical event writes proportional to newly durable batches and prevents a stable retained-row count from hiding repeated replacement of a growing JSON value.
+
+### Reads and repair
+
+Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix.
+
+`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-17 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
+
+### Schema ownership
+
+A pristine database initializes at schema 17. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
+
+### Physical-write regression
+
+The repository regression guard writes 1,000 streamed deltas in 40-event durable batches. After every committed batch it compares every retained physical field, requires cumulative inserts to equal the final row count, and rejects changed or removed rows. It also checks the exact 31-row bound, the largest persisted record against the schema byte limit, and an idle interval with no WAL extent change. These checks prove bounded row structure and catch coarse write amplification; they do not establish device traffic because WAL frames can be overwritten in place and checkpoints also write the main database. Incident-class validation separately samples process physical bytes around active and idle periods and stresses synchronized multi-process access. Lock tests hold `BEGIN IMMEDIATE` in another process and verify bounded waiting and successful continuation.
+
+## Alternatives considered
+
+**Coalesce logical chunk events.** Rejected because it changes sequence references, replay, partial output, and live delivery. Physical records provide the storage reduction while restoring the authoritative log exactly.
+
+**Run a periodic or post-commit compactor.** Rejected because it adds another writer lifecycle, races append and repair, changes revisions without a logical append, and adds disposal work.
+
+**Merge each new batch into the prior packed tail.** Rejected because a stable database and row count can hide repeated delete-and-insert churn. Paced-stream measurement found higher process and WAL writes than the predecessor scalar layout even when the retained database was smaller. Batch-local packing gives up timing-independent row convergence to bound physical writes.
+
+**Use `synchronous=NORMAL` with WAL.** Rejected because it permits a recent committed transaction to roll back after an operating-system crash or power loss. `append()` resolves only after its batch is durable, so the provider explicitly retains SQLite's `FULL` durability level across builds.
+
+**Remove ROWID from `events`.** Rejected because the composite text/integer primary key then becomes the table B-tree key and is repeated through internal pages. On the 105-session comparison corpus, selective Zstandard with ordinary ROWID used 107.02 MB; the otherwise equivalent `WITHOUT ROWID` database used 126.75 MB.
+
+**Set a larger SQLite page size.** Rejected because the retained-size change was negligible: 4 KiB pages used 107.08 MB and 32 KiB pages used 106.89 MB in the layout reconstruction. The larger page also increases WAL-frame and cache granularity. The provider therefore issues no `page_size` pragma.
+
+**Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access.
+
+The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim.
+
+**Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly.
+
+**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 17 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
+
+**Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version.
+
+**Migrate older schemas in place.** Rejected under the pre-release policy. Changing strict column types requires rebuilding the event table, which turns the first append into an unbounded historical rewrite and temporarily duplicates storage. A new database keeps activation explicit and failure predictable.
+
+**Store forked history as a parent reference.** Deferred because it changes independent-session persistence rather than physical row encoding. Codex uses referenced history and excludes referenced or pointer-bearing rollouts from cold compression, but this provider would first need explicit parent retention, deletion, repair, export, and cross-backend semantics. Copying remains the bounded local choice until the session service owns those rules.
+
+**Keep the packed implementation as a versioned sibling.** Rejected because the pre-release repository has no compatibility promise for the scalar format, while two SQLite package names duplicate configuration, documentation, tests, and ownership. Historical benchmark artifacts retain the comparison without exposing a rollback provider.
+
+## Consequences
+
+The canonical SQLite provider preserves every logical persistence, replay, revision, crash-recovery, and model-facing behavior. High-frequency batches use fewer rows and fewer measured process disk-written bytes than the predecessor in paced-stream validation; idle samples add no measured writes. Packing ratio depends on durable batch boundaries, but previously committed rows are immutable outside explicit crash repair.
+
+The cost is no migration from older pre-release SQLite schemas and timing-dependent physical row count. SQLite and Zstandard remain synchronous: each connection uses the configured `busyTimeoutMs` for a competing lock and blocks its JavaScript thread during that wait, while large row encoding and decoding also run on that thread. A cold open yields after an immediate `SQLITE_BUSY` journal-mode transition and starts no further attempt after an open-relative retry cutoff; an in-progress synchronous call may finish later. External SQL tooling must use the provider decoder rather than assuming every physical `events.type` is a logical event type or every payload column is text.
+
+The [JSONL packed-row decision](2026-07-26-packed-chunk-rows-by-default.md), [bounded persistence batching](2026-08-08-bounded-session-persistence-write-batching.md), and original [session-persistence decision](2026-06-14-session-persistence.md) remain active: they respectively own the JSONL format, write scheduling, and backend-neutral service semantics.

+ 81 - 0
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md

@@ -0,0 +1,81 @@
+# Agent Note: SQLite 物理分片行压缩
+
+Status: implemented
+
+[English](2026-08-18-sqlite-physical-chunk-row-compression.md) | 中文
+
+## 问题
+
+标量 [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) 后端为每个逻辑 `SessionEvent` 存储一个物理行。提供方流会生成 token 大小的 `assistant/chunk` 事件,并重复轮次、步骤、块、类型和 envelope 字段,因此事务批处理可以减少提交次数,却不能减少行数或重复 JSON payload。逻辑流不能合并,因为分片边界、序列号、时间戳、回放、部分输出、UI 保真度和 `sourceEventSeqs` 仍然可观察。
+
+一个表示多个事件的物理行会影响追加连续性、崩溃修复、后缀定位、schema 所有权、revision 和陈旧写入方。持久解码规则还必须由包版本固定;可配置 codec 集可能导致同一 schema 版本在不同 Cordis 组合下无法读取。
+
+## 决策
+
+`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
+
+Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
+
+SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
+
+`data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
+
+`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 17 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
+
+### 事务化追加打包
+
+每次追加会获取 `BEGIN IMMEDIATE`、重新检查 schema 所有权、选择可能覆盖最后存储序列的有界物理范围,并根据解码后的尾部推导下一逻辑序列。若不匹配,系统会在变更前拒绝陈旧写入方。Codec 只打包新的持久批次;其插入、会话惰性物化和一次 revision 递增会一起提交或回滚。
+
+普通追加绝不删除或替换既有事件行。固定写后缓冲窗口通常会把高频 delta 收集成有效连续段,而稀疏或显式 flush 的批次可能保持标量形式。这样,物理事件写入量与新增持久批次成正比,稳定的保留行数无法再掩盖对不断增长 JSON 值的反复替换。
+
+### 读取与修复
+
+完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
+
+`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
+
+### Schema 所有权
+
+全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
+
+### 物理写入回归
+
+仓库回归守卫以 40 个事件为持久批次写入 1,000 个流式 delta。它会在每个批次提交后比较所有保留物理字段,要求累计插入数等于最终行数,并拒绝发生变化或被移除的行。它还会检查精确的 31 行上限、最大持久记录不超过 schema 字节上限,并观察空闲区间内 WAL 范围不再变化。这些检查证明行结构有界并捕获粗粒度写放大;它们不能证明设备写流量,因为 WAL 帧可在原位覆写,检查点还会写入主数据库。事故级验证另行采样活动期和空闲期前后的进程物理写入字节,并对同步多进程访问进行压力测试。锁测试在另一个进程中持有 `BEGIN IMMEDIATE`,验证有界等待及之后成功继续。
+
+## 考虑过的替代方案
+
+**合并逻辑分片事件。** 不予采用,因为它会改变序列引用、回放、部分输出和实时投递。物理记录可以在准确恢复权威日志的同时获得存储缩减。
+
+**运行周期性或提交后压缩器。** 不予采用,因为它会增加另一个写入方生命周期,与追加和修复竞争,在没有逻辑追加的情况下改变 revision,并增加资源释放工作。
+
+**把每个新批次合并进已有打包尾部。** 不予采用,因为稳定的数据库与行数可能掩盖反复删除和插入产生的写入流量。节奏化流测量表明,即使保留数据库更小,该方案写入的进程字节与 WAL 字节仍高于此前的标量布局。逐批打包放弃与时序无关的行收敛,以换取有界物理写入。
+
+**在 WAL 模式下使用 `synchronous=NORMAL`。** 不予采用,因为操作系统崩溃或断电后,最近提交的事务可能回滚。`append()` 只会在批次持久化后返回,因此提供方会在不同 SQLite 构建中显式保留 `FULL` 持久性级别。
+
+**从 `events` 移除 ROWID。** 不予采用,因为复合文本/整数主键随后会成为表 B-tree 的键,并在内部页中重复。在 105 个会话的对比语料上,使用普通 ROWID 的选择性 Zstandard 数据库为 107.02 MB;其余条件相同的 `WITHOUT ROWID` 数据库为 126.75 MB。
+
+**设置更大的 SQLite page size。** 不予采用,因为保留体积变化可以忽略:在独立的 page-size 布局重建中,4 KiB page 使用 107.08 MB,32 KiB page 使用 106.89 MB。更大的 page 还会增大 WAL frame 和 cache 粒度。因此提供方不设置 `page_size` pragma。
+
+**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。
+
+最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。
+
+**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
+
+**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
+
+**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
+
+**原地迁移旧 schema。** 预发布策略不采用此方案。改变 strict 列类型需要重建事件表,这会把第一次追加变成无界的历史改写,并暂时复制存储。使用新数据库可让启用行为明确、失败方式可预测。
+
+**把 fork 历史存为父级引用。** 延期处理,因为它改变的是独立会话持久化语义,而不是物理行编码。Codex 使用引用历史,并避免对被引用或带指针的 rollout 做冷压缩;但该提供方首先需要明确父级保留、删除、修复、导出和跨后端语义。在会话服务拥有这些规则之前,复制仍是有界的本地选择。
+
+**把打包实现保留为版本化同级包。** 不予采用,因为预发布仓库不承诺兼容此前的标量格式,而两个 SQLite 包名会重复配置、文档、测试和所有权。历史 benchmark 产物保留对比,无需暴露回滚提供方。
+
+## 后果
+
+标准 SQLite 提供方保留每一项逻辑持久化、回放、revision、崩溃恢复和模型可见行为。在节奏流验证中,高频批次使用的行数和测得的进程磁盘写入字节少于此前布局;空闲样本没有新增测得写入。打包率取决于持久批次边界,但除显式崩溃修复外,已经提交的行保持不可变。
+
+代价是不迁移旧的预发布 SQLite schema,以及取决于时序的物理行数。SQLite 与 Zstandard 都是同步操作:每个连接以配置的 `busyTimeoutMs` 等待竞争锁,该等待期间会阻塞其 JavaScript 线程,大型行的编码与解码也在该线程上执行。冷打开会在 journal-mode 切换立即返回 `SQLITE_BUSY` 后让出执行,并在从打开时计算的重试截止点后不再发起新尝试;正在执行的同步调用可能更晚才完成。外部 SQL 工具必须使用提供方解码器,而不能假定每个物理 `events.type` 都是逻辑事件类型或每个 payload 列都是文本。
+
+[JSONL 打包行决策](2026-07-26-packed-chunk-rows-by-default.md)、[有界持久化批处理](2026-08-08-bounded-session-persistence-write-batching.md)和原始[会话持久化决策](2026-06-14-session-persistence.md)继续保持 active:它们分别负责 JSONL 格式、写入调度以及后端无关的服务语义。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.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 .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md
+2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698
+2026-08-06-plan-narrow-viewport-regression.zh.md: 37060129364c65b02cc1729331fc7334797863db

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md

@@ -0,0 +1,33 @@
+# Agent Note: narrow-viewport plan chip click-area regression test
+
+Status: implemented
+
+English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md)
+
+## Problem
+
+The external report dsh-external/issues#107 (clustered internally as deepseek-harness#1406) measured that at viewports between 760px and 850px the plan control and the model selector overlapped, with the model selector covering the plan control's click area so plan mode could not be left by mouse at 800×720. Its acceptance list asked for a browser regression test asserting that the plan center hit-tests to the plan button.
+
+The browser regression test reproduced the report on current master: at 800×720 the plan chip and the model trigger overlapped by 36.9px and the chip's center hit-tested to the trigger's label. The composer control row is `display: flex; justify-content: space-between` with `.trailing { flex: none }`: when the combined control width exceeds the card, the shrinking `.tools` group keeps its flow children inside its `min-width: 0` box, so the chip — the last flow child before the overflow — is painted over the trailing group. The plan-control form changed since the report (select → chip, `c20b988166`/`fe91919346`) and the row gained adaptive behavior (`c8c75ec891`, [web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)), but the row had no wrap, so the overlap survived both.
+
+## Decision
+
+The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold.
+
+Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no model call in any mode and no API key in replay/refresh; a providers-only fixture mounts the model catalog without a script to consume. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program.
+
+The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive.
+
+## Alternatives considered
+
+**Seed a cold session (composer-tab-geometry pattern).** Rejected: the exit path executes `/plan off` through `commands.execute`, which needs the live agent a cold seeded session does not have; `connectFreshWorkspace` keeps one, matching the product's user path.
+
+**Pin absolute bounding boxes in the golden.** Rejected: chip and trigger widths depend on the installed fonts, so absolute coordinates would churn across platforms without a behavior change.
+
+**Reuse the plan-review fixture shape (exit_plan_mode review takeover).** Rejected: the takeover replaces the composer's control row, which is the surface under test.
+
+**Container-query label folding for the chip and/or the model trigger.** Rejected for the fix: two packages (ui-plan, ui-model) would need calibrated thresholds and the chip's own icon-only fold still leaves ~7px of overlap at the reported viewport unless the trigger folds too. Wrapping is one rule in one package and holds at every width.
+
+## Consequences
+
+Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. The test needs no API key in replay/refresh modes: plan mode toggles through the command handler without a model round, and a providers-only replay fixture (no recorded script, consumption check skipped) mounts the model directory so the trigger renders its real long label — the width that made the reported overlap measurable; the test asserts that label before measuring. The golden is compared in replay and record modes and rewritten in refresh mode.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 窄视口下 Plan chip 点击区域回归测试
+
+Status: implemented
+
+[English](2026-08-06-plan-narrow-viewport-regression.md) | 中文
+
+## 问题
+
+外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。
+
+浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。
+
+## 决策
+
+控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。
+
+新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试在任何模式下都无需模型调用,仅在 replay/refresh 下无需 API key;providers-only fixture 挂载模型目录而无脚本可消费。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。
+
+几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。
+
+## 备选方案
+
+**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有;`connectFreshWorkspace` 保留一个,与产品的用户路径一致。
+
+**golden 固定绝对 bounding box。** 否决:chip 与 trigger 宽度依赖安装字体,绝对坐标会在平台间漂移而不反映行为变化。
+
+**复用 plan-review fixture 形态(exit_plan_mode review takeover)。** 否决:takeover 会替换 composer 控制行,而被测表面正是控制行。
+
+**chip 与/或模型 trigger 的容器查询 label 折叠。** 否决(作为修复):两个包(ui-plan、ui-model)需要各自标定阈值,且 chip 单独折叠为 icon-only 在报告视口下仍剩约 7px 重叠,除非 trigger 也折叠。换行是一个包中的一条规则,且在所有宽度下成立。
+
+## 后果
+
+任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。测试在 replay/refresh 模式下无需 API key:Plan 模式经命令 handler 切换,不经模型回合;providers-only replay fixture(无录制脚本,跳过消费检查)挂载模型目录,使触发器渲染真实的长标签——正是使报告重叠可测量的宽度;测试在测量前断言该标签。golden 在 replay 与 record 模式下比较,在 refresh 模式下重写。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.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 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md
+2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800
+2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md

@@ -0,0 +1,43 @@
+# Agent Note: The feedback note editor floats above the transcript in a popover
+
+Status: implemented
+
+English | [中文](2026-08-13-feedback-note-editor-popover.zh.md)
+
+## Problem
+
+The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel.
+
+A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM.
+
+The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input.
+
+## Decision
+
+The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out.
+
+**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward.
+
+**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`).
+
+**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected.
+
+## Alternatives considered
+
+**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not.
+
+**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do.
+
+**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host.
+
+## Consequences
+
+With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes.
+
+`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click.
+
+The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`.
+
+Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub.
+
+A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change.

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md

@@ -0,0 +1,43 @@
+# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方
+
+Status: implemented
+
+[English](2026-08-13-feedback-note-editor-popover.md) | 中文
+
+## Problem
+
+消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap` 且 `height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。
+
+一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。
+
+同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token:`border-secondary`、`bg-primary`、`interactive-bg-primary` 与 `label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。
+
+## Decision
+
+备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。
+
+**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。
+
+**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。
+
+**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。
+
+## Alternatives considered
+
+**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。
+
+**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。
+
+**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。
+
+## Consequences
+
+编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。
+
+`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。
+
+`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。
+
+有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。
+
+520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.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 .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md
+2026-08-18-rail-search-outside-click-self-dismissal.md: 9893b3a2456b9a592e1feb107d21404e043dee78
+2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 91e343b8843dc02ca9c1be2b145e79beb84e17b7

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md

@@ -0,0 +1,27 @@
+# Agent Note: Rail search keeps its expansion when the opening click reaches document
+
+Status: implemented
+
+English | [中文](2026-08-18-rail-search-outside-click-self-dismissal.zh.md)
+
+## Problem
+
+The collapsed sidebar's rail search button arms the rail gesture (`searchOnExpand`), expands the search affordance (`searchExpanded`), and requests sidebar expansion — designed to land the user in a focused search input once the column slides open. In a real browser the gesture never completed: the sidebar expanded but the search box stayed closed and unfocused.
+
+The initiating click destroys its own effect. React dispatches the rail button's handler mid-bubble; the state flip renders the wide header and mounts the WorkspaceBrowser's outside-click dismissal listener on `document` during that same dispatch. The click then keeps bubbling and reaches `document` with the now-unmounted rail button as its target — outside `searchRoot` — so the freshly mounted listener immediately collapses the search it was opening. The package test missed this because `fireEvent.click` on the button does not re-bubble through listeners mounted during dispatch the way a real browser event does.
+
+## Decision
+
+The outside-click dismissal listener does not mount while the rail gesture is in flight: its effect returns early while `searchOnExpand` is set, and `searchOnExpand` already ends exactly when the gesture settles (focus lands in the input after the column slide). After settle, outside clicks dismiss the search as before. A regression test replays the real-browser order — rail click, wide flip, then the same click arriving at `document` — and requires the search to stay expanded through it and to dismiss on the next genuine outside click.
+
+## Alternatives considered
+
+**Stop propagation on the rail button's click.** Suppressing bubbling at the initiator couples the rail button to a listener it cannot see, and every other expansion path — a future keyboard shortcut, another rail entry — would reintroduce the bug. The listener owns dismissal, so the listener carries the guard.
+
+**Defer listener attachment by a frame or timeout.** A raw delay encodes the symptom (the click arrives "too early") instead of the cause (a gesture is in flight). `searchOnExpand` is already the explicit in-flight state with the correct end point; a frame boundary is neither.
+
+**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click today does not — to fix a problem scoped to one gesture.
+
+## Consequences
+
+The rail search gesture works end to end in the assembled application, pinned by an `apps/web` real-browser scenario: a real click travels through the collapsed rail, the wide flip, and the document-level bubble, and the search stays expanded with focus landing in the input. During the in-flight window (~300 ms column slide) an outside click does not dismiss the search; that window ends the moment focus lands. The package-level regression test additionally pins the guard's timing at the unit level.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 轨道搜索在展开点击到达 document 时保持展开
+
+Status: implemented
+
+[English](2026-08-18-rail-search-outside-click-self-dismissal.md) | 中文
+
+## 问题
+
+收起侧边栏的轨道搜索按钮会置位轨道手势标志(`searchOnExpand`)、展开搜索控件(`searchExpanded`)并请求侧边栏展开——设计意图是列滑开后让用户直接落在已聚焦的搜索输入框里。但在真实浏览器中这个手势从未完成:侧边栏展开了,搜索框却保持关闭且未聚焦。
+
+发起手势的那次点击摧毁了它自己的效果。React 在冒泡中途派发轨道按钮的处理器;状态翻转渲染出宽态头部,并在同一次派发期间把 WorkspaceBrowser 的"点击外部收起搜索"监听器挂到 `document` 上。随后这次点击继续冒泡到达 `document`,其 target 是已卸载的轨道按钮——位于 `searchRoot` 之外——于是刚挂上的监听器立刻收起了它正要打开的搜索。包级测试没有抓到这个问题,因为 `fireEvent.click` 在按钮上触发时,不会像真实浏览器事件那样继续冒泡穿过派发期间新挂载的监听器。
+
+## 决策
+
+轨道手势进行期间不挂载"点击外部收起"监听器:其 effect 在 `searchOnExpand` 置位期间提前返回,而 `searchOnExpand` 本就精确终止于手势落定之时(列滑动结束、焦点落入输入框)。落定之后,外部点击照旧收起搜索。一个回归测试重放真实浏览器的顺序——轨道点击、宽态翻转、同一次点击到达 `document`——要求搜索在此过程中保持展开,并在下一次真正的外部点击时收起。
+
+## 备选方案
+
+**在轨道按钮的点击上阻止冒泡。** 在发起方抑制冒泡会让轨道按钮耦合到一个它看不见的监听器,而且其他每条展开路径——未来的键盘快捷键、另一个轨道入口——都会重新引入此缺陷。收起由监听器负责,守卫就应由监听器承载。
+
+**将监听器挂载延迟一帧或一个定时器。** 裸延迟编码的是症状(点击来得"太早")而非成因(手势正在进行)。`searchOnExpand` 已经是带有正确终点的显式进行中状态;帧边界两者都不是。
+
+**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而如今完成的点击才会——只为修复一个局限于单个手势的问题。
+
+## 影响
+
+轨道搜索手势在组装后的应用中端到端可用,由 `apps/web` 的真实浏览器场景钉住:真实点击穿过收起轨道、宽态翻转与 document 级冒泡,搜索保持展开且焦点落入输入框。在手势进行窗口内(约 300 ms 列滑动)外部点击不会收起搜索;该窗口在焦点落定的瞬间结束。包级回归测试另外钉住了单元层面的守卫时序。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md
-2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1
-2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385
+2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1
+2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md

@@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the
 
 ## Decision
 
-The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route.
+The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route.
 
 ## Offload is conversion, not history
 
@@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit
 ## Related
 
 - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size).
+- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route.
 
 ## Consequences
 

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md

@@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型
 
 ## Decision
 
-pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。
+pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。
 
 ## offload 是转换而非历史
 
@@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE
 ## Related
 
 - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。
+- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。
 
 ## Consequences
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md
+2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43
+2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md

@@ -0,0 +1,33 @@
+# Agent Note: Tool-row file-open failures stay visible
+
+Status: implemented
+
+English | [中文](2026-08-18-tool-row-file-open-failure.zh.md)
+
+## Problem
+
+Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try.
+
+The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal.
+
+## Decision
+
+The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog.
+
+The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy.
+
+The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix.
+
+## Alternatives considered
+
+- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target.
+- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two.
+- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture.
+
+## Consequences
+
+A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe.
+
+## Testing
+
+Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Tool-row file-open failures stay visible
+
+Status: implemented
+
+[English](2026-08-18-tool-row-file-open-failure.md) | 中文
+
+## 问题
+
+工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。
+
+[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。
+
+## 决策
+
+inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。
+
+对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。
+
+Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。
+
+## 考虑过的替代方案
+
+- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。
+- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。
+- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。
+
+## 后果
+
+从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。
+
+## 测试
+
+包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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 .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
-2026-07-16-persistent-pty-sessions.md: 56fb4542bef2f6ad43f6b3a2c94772d54d198534
-2026-07-16-persistent-pty-sessions.zh.md: 87c7d7c5f41c91e1c57b33e876c13ee6b35f82cc
+2026-07-16-persistent-pty-sessions.md: 3ca35cdd59f38570be478fddf4213335665556a5
+2026-07-16-persistent-pty-sessions.zh.md: ae31481560b446b894abd215ba4434f7a504a3ea

+ 1 - 1
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md

@@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
 - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited.
 - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol.
 - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work.
-- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics.
+- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md).
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md

@@ -134,7 +134,7 @@ plugins:
 - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。
 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。
 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。
-- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。
+- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。
 
 ## 备选方案
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-21-cross-session-references.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 .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
-2026-07-21-cross-session-references.md: f49c75a9d4e3304b44d12a3df85430ffb494b8a9
-2026-07-21-cross-session-references.zh.md: 81a1970497bf175948a70bb1982f336f19d4d19e
+2026-07-21-cross-session-references.md: 48a3241871f0b65270aaf74e42c9d02c45eaf027
+2026-07-21-cross-session-references.zh.md: ef723115f0de4312c0f4b1d215303b4a6a31be47

+ 16 - 14
.agents/notes/implemented/feature/2026-07-21-cross-session-references.md

@@ -6,33 +6,35 @@ English | [中文](2026-07-21-cross-session-references.zh.md)
 
 ## Problem
 
-TUI users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, filtering by cited source-event seqs, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax.
+Web users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax.
 
 ## Decision
 
-`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log.
+`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Its outer `agent/pre-step` listener parses canonical mentions in accepted direct user messages and calls `prepare()` without adding reference behavior to a host gateway. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log.
 
-`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
+`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. Web receives that URI inside the Host-produced `@[label](uri)` mention and keeps it behind an atomic session chip; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
 
 The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: discovery matches id, cwd, or the latest folded title, while message bodies remain outside the candidate layer. Non-empty queries batch title observations across the visible corpus with bounded persisted-log concurrency and cancellation; a dedicated title index can replace that discovery path without changing reference identity or preparation.
 
 ## Snapshot and projection
 
-Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session.
+Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partial context: any read, cancellation, validation, or budget error ends the turn before the accepted messages enter model-visible history. Cancellation races in-flight discovery and exact reads, so the listener settles promptly even when a persistence backend cannot interrupt its pending operation. A queued message captures each source when it reaches `agent/pre-step`; later source messages, compaction, deletion, or persistence replacement cannot change the context recorded in the target session.
 
-Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery.
+Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery.
 
-One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope.
+One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately after the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope.
 
 ## Message ownership
 
-TUI owns the snapshot/direct-message transaction without extending the generic inbox record. While the agent is idle, it installs a one-shot outer `agent/pre-step` listener before `followup()`; an enter decision receives the snapshot as another message, while rejection or an earlier ordinary discard releases the listener and writes neither message. While the agent is running, TUI calls `inject(snapshot)` then `steer(prompt)`, placing both in the next-step inbox for the same later claim. A rejecting or failed pre-step leaves that claimed pair removed; messages inserted after the claim remain pending. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary.
+The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately after that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering.
 
-Reference preparation is not a new steering protocol and does not create a turn by itself. Idle delivery uses `followup()` and pre-step entry; running delivery uses the shared next-step inbox while retaining snapshot order.
+Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path.
 
 ## Host adapters
 
-TUI combines session candidates with the existing `@` file provider. Candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal.
+The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id.
+
+Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with the direct message immediately before it and renders a compact source summary instead of exposing the snapshot JSON.
 
 The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services.
 
@@ -45,16 +47,16 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b
 - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only.
 - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer.
 - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts.
-- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt.
-- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble.
+- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. The domain listener can prepare the final claimed message without enlarging every direct prompt.
+- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets Web hide background bytes from the direct user bubble.
 - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history.
 - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity.
-- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state.
+- **Reread the source after the model step enters** — rejected because target replay would depend on external mutable state instead of the logged snapshot.
 
 ## Verification
 
-Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. One keyless terminal snapshot types a title-only substring against an opaque session id and pins the rendered candidate. Another keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string.
+Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, Chat-projected following-recall association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path.
 
 ## Consequences
 
-The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.
+The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.

+ 16 - 14
.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md

@@ -6,33 +6,35 @@ Status: implemented
 
 ## 问题
 
-TUI 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、按被引用来源事件 seq 过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。
+Web 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。
 
 ## 决策
 
-`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。
+`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。它的外层 `agent/pre-step` 监听器会解析已接受直接用户消息中的规范 mention,并调用 `prepare()`,宿主网关无需添加引用行为。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。
 
-`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
+`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。Web 接收由 Host 生成、包含该 URI 的 `@[label](uri)` 提及标记,并把它封装为原子 session chip;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
 
 该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是依赖项:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。
 
 ## 快照与投影
 
-准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。
+准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分上下文:任何读取、取消、校验或预算错误都会在已接受消息进入面向模型的历史之前结束该轮次。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,监听器也能及时结束等待。queued 消息到达 `agent/pre-step` 时会捕获每个源;此后源会话新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中记录的上下文。
 
-投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。
+投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。
 
-系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。
+系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之后。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。
 
 ## 消息所有权
 
-TUI 负责快照/直接消息事务,不扩展通用收件箱记录。agent 空闲时,它会在调用 `followup()` 前安装一次性的外层 `agent/pre-step` 监听器;enter 决策会把快照作为另一条消息接收,而 reject 或更早的普通丢弃会释放监听器,并且不写入任何消息。agent 运行时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,把两者放入 next-step inbox,等待后续同一次领取。pre-step reject 或失败会使这对已领取消息维持已移除状态;领取后插入的消息继续等待。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。
+该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧后。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。
 
-引用准备过程不是新的 steering 协议,本身也不会创建轮次。空闲交付使用 `followup()` 和 pre-step 进入决策;运行期间的交付使用共享 next-step inbox,并保持快照顺序。
+引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。
 
 ## 宿主适配器
 
-TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
+统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。
+
+Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与紧邻其前的直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。
 
 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。
 
@@ -45,16 +47,16 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询
 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。
 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。
 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。
-- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。
-- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。
+- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域监听器可以准备最终领取的消息,无需扩大每条直接提示词。
+- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 Web 从直接用户气泡中隐藏背景字节。
 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。
 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。
-- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。
+- **模型步骤进入后重新读取源会话**:不予采纳,因为目标回放会依赖可变的外部状态,而不是已记录的快照。
 
 ## 验证
 
-单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、能力缺失和压缩场景下的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。
+单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、Chat 投影的后继召回关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。
 
 ## 后果
 
-新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。
+新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI 组合会为 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
-2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9
-2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7
+2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5
+2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments
 
 Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history.
 
-This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
+This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
 
 The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle.
 
@@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations
 
 The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast.
 
-The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
+Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`.
 
 Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
 
@@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc
 | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. |
 | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
 | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
-| `packages/llm/llm-deepseek` | Reject image content explicitly. |
+| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. |
 | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
 | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. |
 | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
@@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability
 
 ### Implementation
 
-The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
+The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
 
 No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。
 
-这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
+这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
 
 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。
 
@@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都
 
 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。
 
-Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
+Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。
 
 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
 
@@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
 | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
 | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
-| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
+| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 |
 | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
 | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 |
 | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 |
@@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 
 ### 实现
 
-已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
+已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
 
 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md
+2026-07-27-web-file-and-session-references.md: 51447d0c22c2e5beb4eae03e7f955239525fc16d
+2026-07-27-web-file-and-session-references.zh.md: f2f87aabaa818fe5d104b0ee1488658c792f59b9

+ 49 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md

@@ -0,0 +1,49 @@
+# Agent Note: Web file and session references
+
+Status: implemented
+
+English | [中文](2026-07-27-web-file-and-session-references.zh.md)
+
+## Problem
+
+The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser or binding session identity to a display label.
+
+## Decision
+
+Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. The source suppresses its raw group title through loading and settled states because those section headings own the visible grouping. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other.
+
+The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick is an atomic composer reference with a file glyph and filename; its serialized form remains path-only prompt text. A directory stays editable path text with a folder glyph and retriggers completion below its trailing slash.
+
+A session pick is a structured composer reference. Its visible form uses a chat-bubble glyph and business-color session title without a capsule, while its clipboard and model form is the canonical `@[label](dsh-session:…)` mention produced by the Host. The complete `@label` display text remains in the transparent textarea, and the same-size backdrop colors that range and replaces its leading marker with the domain glyph. Native glyph metrics therefore determine width, wrapping, selection, and caret placement without truncation. The occurrence range retains reference identity for serialization; Backspace or Delete at its boundary removes it whole, and editing inside it turns the remaining characters into ordinary text. Ordinary `session.prompt` delivery carries the canonical mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately after that message. The recalled-context row uses the same chat glyph while other context keeps the document glyph. The API Proxy contains no reference-specific route, dependency, or error code.
+
+The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Its session-store mirror persists each occurrence's canonical clipboard projection, so remounting without the occurrence table retains a parseable reference instead of a display-only label. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order and associates exact session labels only from the immediately following sourced recall, which preserves multi-word titles and keeps consecutive references independent. It decorates recognized file and session mentions as icon-and-text references, treats unquoted `@path` tokens including extensionless basenames as files, leaves sentence punctuation outside the reference range, and keeps snapshot JSON behind the collapsed recall row.
+
+## Reference transaction
+
+```text
+type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference
+       → serialize draft → ordinary session.prompt enqueue
+       → agent/pre-step parses mentions → capture sources → readable prompt + context
+```
+
+File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination.
+
+## Alternatives considered
+
+**Implement file discovery and grammar inside the Web client.** Rejected because browser-side code cannot safely access the Host workspace, while duplicating grammar, ranking, bounds, and invalidation would drift from the Host provider.
+
+**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips.
+
+**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence.
+
+**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display.
+
+**Clear the composer before prompt admission settles.** Rejected because a transport or admission failure would lose the only editable copy of the request and visually claim acceptance that never occurred.
+
+## Verification
+
+Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, disabled-layer ownership, canonical draft persistence across remount, adjacent-reference and adjacent-text projection, extensionless file and sentence-punctuation rendering, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and following-recall association for multi-word and consecutive labels. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition, and replays a multi-word session label in direct-before-recall order.
+
+## Consequences
+
+Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. File and session discovery are unary Remote contracts on the owning services, so generated client types replace handwritten RPC interfaces and browser bundles remain free of Node APIs. Candidate lookup failures remain quiet menu degradation. Reference preparation failures occur after prompt acceptance and end the agent turn. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`.

+ 49 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: Web 文件与会话引用
+
+Status: implemented
+
+[English](2026-07-27-web-file-and-session-references.md) | 中文
+
+## 问题
+
+Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统或把会话身份绑定到显示标签。
+
+## 决策
+
+Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。该 source 在加载和已结算状态下都会隐藏原始组标题,因为可见分组由这些分组标题拥有。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。
+
+文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件会创建带文件图标与文件名的原子输入框引用,其序列化形式仍只是路径提示词文本。目录保持为带文件夹图标的可编辑路径文本,并在尾部斜杠后重新触发补全。
+
+选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。完整的 `@label` 展示文本会保留在透明 textarea 中,同尺寸 backdrop 会为这段范围着色,并把开头的 marker 替换为对应领域图标。因此宽度、换行、选择区与光标位置都由原生字形度量决定,不会截断。occurrence 范围会保留引用身份以供序列化;在边界按 Backspace 或 Delete 会整段删除引用,在范围内部编辑则会把剩余字符转为普通文本。普通 `session.prompt` 投递会原样携带规范 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧后。召回上下文行使用同一个聊天图标,其他上下文保留文档图标。API Proxy 不包含引用专用路由、依赖或错误码。
+
+输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。它写入会话 store 的镜像会持久化每个 occurrence 的规范剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,仍会保留可解析的引用,而不是仅供显示的标签。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,并且只从紧随其后的带来源召回中关联准确的会话标签,因此既能保留多词标题,也能让连续引用彼此独立。它会把识别到的文件与会话 mention 装饰成图标加文字的引用,把包括无扩展名 basename 在内的未加引号 `@path` token 视为文件,将句末标点留在引用范围之外,并把快照 JSON 保留在默认收起的召回行中。
+
+## 引用事务
+
+```text
+type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference
+       → serialize draft → ordinary session.prompt enqueue
+       → agent/pre-step parses mentions → capture sources → readable prompt + context
+```
+
+文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。
+
+## 备选方案
+
+**在 Web 客户端内部实现文件发现与语法。** 不予采纳,因为浏览器侧代码无法安全访问宿主工作区,而且重复的语法、排序、边界和失效处理会与宿主提供方产生偏差。
+
+**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。
+
+**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。
+
+**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。
+
+**提示词准入结算前清空输入框。** 不予采纳,因为传输或准入失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。
+
+## 验证
+
+包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、禁用状态下的层级归属、跨重新挂载的规范草稿持久化、相邻引用及相邻文本条件下的引用投影、无扩展名文件与句末标点渲染、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及多词与连续标签的后继召回关联。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签。
+
+## 后果
+
+Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。文件和会话发现都是所属服务上的一元 Remote 契约,因此生成的客户端类型会替代手写 RPC 接口,浏览器 bundle 中也不包含 Node API。候选查询失败仍会让菜单静默降级。引用准备失败发生在提示词已接受之后,并会结束 agent 轮次。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: cdf2b12e69b359b8ab00d4b107aa78a21691ac4e
-2026-07-27-web-subagent-conversations.zh.md: 697bfeef86e2ef456aed6ee6ad8eae1173c96a6f
+2026-07-27-web-subagent-conversations.md: d20e55429d04d4308ae26d001575e664058c7dea
+2026-07-27-web-subagent-conversations.zh.md: 67c9b5224abea6c0710303ae56e315ba34c7ac09

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -85,7 +85,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
 
 **Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages.
 
-**Create a new UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the coherent owner for catalog and addressed-child presentation.
+**Put Host-backed file and session references in this package.** Rejected because catalog and addressed-child presentation depend on subagent lineage, while combined reference discovery is a separate Host capability consumed by [`ui-reference`](../../../../packages/client/ui-reference/README.md).
 
 **Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -85,7 +85,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 **将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端能力;webserver 只承载已校验的消息。
 
-**新建 UI 包。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录与已寻址 child 呈现的统一 owner。
+**把由 Host 支撑的文件与会话引用放进本包。** 不予采纳,因为目录与已寻址 child 呈现依赖 subagent 谱系,而组合引用发现是独立的 Host 功能,由 [`ui-reference`](../../../../packages/client/ui-reference/README.md) 消费。
 
 **自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
-2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d
-2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3
+2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68
+2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md

@@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil
 
 ## Consequences
 
-Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`.
+Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)).
 
 ## Risks
 
-- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error.
+- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry.
 - Relative paths without a session cwd are forwarded verbatim and may fail on the host.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md

@@ -23,9 +23,9 @@ Status: implemented
 
 ## 后果
 
-点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。
+点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。
 
 ## 风险
 
-- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。
+- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。
 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
-2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3
-2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624
+2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782
+2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211

+ 17 - 7
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md

@@ -8,29 +8,39 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md)
 
 The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen.
 
+Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese.
+
 ## Decision
 
-**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
+**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
+
+**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI.
 
 **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
 
-**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
+**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
 
 **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question.
 
-**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature.
+**`<html lang>` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. The attribute carries a BCP 47 tag rather than the app's locale id: `zh` alone leaves the script ambiguous, so the shipped Chinese copy declares `zh-CN`.
+
+**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback.
 
 ## Alternatives considered
 
 - **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading.
 - **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this".
 - **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it.
-- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
+- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
+- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead.
+- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice.
 - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end.
+- **Serving `<html lang>` per request, or leaving the static attribute alone**: computing it server-side would need the request's `Accept-Language` to re-derive what the client resolves anyway, duplicating the rule in two places and still losing to a stored preference the server does not read. Leaving it static is what made the attribute permanently wrong for one language or the other. Setting it from the resolved locale keeps one source of truth.
 
 ## Consequences
 
-- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
-- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen".
-- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all).
+- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
+- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on.
+- `<html lang>` now reports the language on screen in both directions, which closes [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160). A client that never activates the locale plugin keeps the served default, so the attribute degrades to the old static behavior rather than to a blank value.
+- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead.
 - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation.

+ 17 - 7
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md

@@ -8,29 +8,39 @@ Status: implemented
 
 设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。
 
+读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。
+
 ## Decision
 
-**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
+**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
+
+**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
 
 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
 
-**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。
+**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。
 
 **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
 
-**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。
+**`<html lang>` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。该属性承载 BCP 47 标签而非应用内部的 locale id:单独的 `zh` 会使文字(script)含义不明,因此已提供的中文文案声明 `zh-CN`。
+
+**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。
 
 ## Alternatives considered
 
 - **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
 - **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。
 - **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。
-- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
+- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
+- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。
+- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。
 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。
+- **按请求服务 `<html lang>`,或干脆不管这个静态属性**:在服务端计算它需要用请求的 `Accept-Language` 去重新推导客户端本就会解析的结果,使同一条规则在两处重复,而且仍会输给服务端并不读取的存储偏好。放任其保持静态,正是该属性对某一种语言永远错误的原因。依据解析出的 locale 来设置,可保持单一真源。
 
 ## Consequences
 
-- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
-- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。
-- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。
+- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
+- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。
+- `<html lang>` 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160)。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。
+- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。
 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.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 .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
-2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374
-2026-08-03-web-search-source-scroll.zh.md: 8ac1158d054f739bb1f76c87570ac1e75b77e05d
+2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b
+2026-08-03-web-search-source-scroll.zh.md: bc1abb5215c618809e79f56d1f9bd6c1ee9dbf15

+ 5 - 5
.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md

@@ -8,13 +8,13 @@ English | [中文](2026-08-03-web-search-source-scroll.zh.md)
 
 The `web_search` result card (`WebBlock`, `packages/client/ui-primitives/src/WebBlock.tsx`) rendered its source list with a head/tail collapse: past a `maxSources` count (16 in the details panel, 8 in the chat row via `CHAT_WEB_MAX_SOURCES`) it drew the first `ceil(max/2)` sources, an `… 其余 N 条来源` expand button, then the last `max - ceil(max/2)`, mirroring `TerminalBlock`'s output cap. A user reading the card saw `来源列表已截断` and assumed the frontend had dropped sources it was holding.
 
-It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts the provider's sources to the tool's `searchMaxResults` bound (default 8) and sets `truncated`, and that one capped list feeds both the model-facing render text and the card's `presentationMeta`. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
+It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts each provider result to the tool's `searchMaxResults` bound (default 8); a multi-query call then deduplicates, interleaves, and caps the combined sources at the same bound. The final capped list feeds both the model-facing render text and the card's `presentationMeta`, so the card never holds more sources than the tool returned. The collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
 
 ## Decision
 
 `WebBlock`'s search arm renders every source it receives in one `<ol className={css.sources}>`, with no head/tail slicing, no expand button, and no `maxSources` prop. `.sources` (`WebBlock.module.css`) gets a fixed `max-height` and `overflow-y: auto`, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field.
 
-The model side is unchanged: the seam still caps sources at `searchMaxResults`, the model-facing render text is untouched, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle.
+The model side remains capped at `searchMaxResults`: the seam caps each provider result, the multi-query consumer caps a combined list, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the final tool source list in full and scrollable, instead of collapsing its middle.
 
 That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting `dsh-spill-policy` breaks that correspondence for an oversized result: `tools/post-execute` replaces the model-facing `content` with a preview plus a spill locator and leaves `presentationMeta` whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context.
 
@@ -36,11 +36,11 @@ Every source the tool returned is always in the DOM, so no source the view carri
 
 ## Testing
 
-`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. The `packages/web/tool-web` tests are unchanged — the model side did not move.
+`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. `packages/web/tool-web` independently pins the single- and multi-query model-side caps.
 
-jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped `searchMaxResults` keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over `scrollHeight` 574 against `clientHeight` 320. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`, while the card is collapsed at capture time so its `<ol>` is out of the DOM and the summary row carries no source count.
+jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`.
 
 ## Related
 
-- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.
+- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the final capped list.
 - [Web result card frontend](2026-07-30-web-result-card-frontend.md) — owns `WebBlock`, the single `web-card-model` derivation, and the render sites that draw the card; this note replaces the source-list collapse it specified, and its other decisions (one component for both kinds, the http(s) link allowlist, the single derivation, the resident posture) stand.

+ 5 - 5
.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md

@@ -8,13 +8,13 @@ Status: implemented
 
 `web_search` 结果卡片(`WebBlock`,`packages/client/ui-primitives/src/WebBlock.tsx`)此前用首尾折叠渲染它的来源列表:超过 `maxSources` 数量(详情面板为 16,聊天行经由 `CHAT_WEB_MAX_SOURCES` 为 8)时,它画出前 `ceil(max/2)` 条来源、一个 `… 其余 N 条来源` 展开按钮,再画出末尾 `max - ceil(max/2)` 条,仿照 `TerminalBlock` 的输出上限机制。用户阅读该卡片时看到 `来源列表已截断`,会以为前端丢弃了它正持有的来源。
 
-其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把 provider 的来源裁剪到工具的 `searchMaxResults` 上限(默认 8)并置位 `truncated`,而这一份被裁剪过一次的列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`。卡片持有的来源绝不会多于这一次裁剪的产物。因此这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
+其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把每个提供方结果裁剪到工具的 `searchMaxResults` 上限(默认 8);多查询调用随后对组合来源去重、交错并限制在同一个上限内。最终的有界列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`,因此卡片持有的来源绝不会多于工具返回的来源。这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
 
 ## 决策
 
 `WebBlock` 的 search 分支把它收到的每一条来源都渲染进单个 `<ol className={css.sources}>`,不做首尾切片、不设展开按钮、也不带 `maxSources` prop。`.sources`(`WebBlock.module.css`)获得一个固定的 `max-height` 与 `overflow-y: auto`,因此长于卡片高度的列表在原地滚动,而非撑大卡片或隐藏行。该高度是卡片几何形状的一个设计常量,因此放在 CSS 里,而非插件配置字段。
 
-模型侧不变:seam 仍在 `searchMaxResults` 处封顶来源,面向模型的 render 文本未动,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出 seam 产出的这份列表,而非折叠其中段。
+模型侧仍受 `searchMaxResults` 限制:seam 限制每个提供方结果,多查询消费方限制组合列表,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出最终工具来源列表,而非折叠其中段。
 
 只要工具下游没有单独改写结果 content,这份列表就是模型读到的那份。挂载了 `dsh-spill-policy` 的部署会对超限结果打破这一对应:`tools/post-execute` 把面向模型的 `content` 替换为预览加 spill 定位符,而 `presentationMeta` 原样保留,因此卡片仍画出全部来源,模型读到的却是一段有界摘录。所以卡片的约定是它收到的 view,不是模型的上下文。
 
@@ -36,11 +36,11 @@ Status: implemented
 
 ## 测试
 
-`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧没有改动。
+`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 独立固定单查询与多查询的模型侧上限。
 
-jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 返回 12 条提供方结果,每条带标题、引用摘录与日期。这首先在真实组合里端到端钉住 seam 的裁剪——出厂 `searchMaxResults` 保留 8 条,面向模型的 render 文本含这 8 条标题、不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,`scrollHeight` 为 574、`clientHeight` 为 320。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。录制的模型流与 aria golden 都未变动:回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点;捕获时卡片处于折叠状态,其 `<ol>` 不在 DOM 中,摘要行也不携带来源数量。
+jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 为两个查询分别返回 6 条结果,每条带标题、引用摘录与日期。真实组合会观察两次提供方请求,并固定工具的轮询组合上限——出厂 `searchMaxResults` 保留代表两个查询的 8 条来源,面向模型的 render 文本不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,滚动主体高于容器。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点。
 
 ## 相关文档
 
-- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。
+- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;最终有界列表的来源。
 - [Web result 卡片前端](2026-07-30-web-result-card-frontend.md) —— `WebBlock`、唯一的 `web-card-model` 派生,以及绘制该卡片的各渲染点由它拥有;本笔记替换掉它所规定的来源列表折叠,它的其余决策(一个组件绘制两种 kind、http(s) 链接 allowlist、单一派生、常驻姿态)依然成立。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
-2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4
-2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c
+2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c
+2026-08-04-claude-code-and-codex-subagent-backends.zh.md: fcb1aac71be2da9d907ad67867c763e3051baec5

+ 15 - 15
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md

@@ -12,12 +12,12 @@ The product integrations must not become second owners for task text, cwd, cance
 
 ## Decision
 
-The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration.
+The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
 
 Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation.
 
 ```text
-fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process
+configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process
   foreground <- final product outcome
   background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls
   both -> provider disposal -> dsh-subprocess -> whole-tree exit
@@ -34,39 +34,39 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product
 
 ## Codex provider
 
-`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision.
+`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex`, resolves the `codex` bin declared by its pinned `@openai/codex@0.147.0` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision.
 
 Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
 
-`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail.
+`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic.
 
 For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
 
-An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable.
+An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal.
 
 Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
 
 ## Claude Code provider
 
-`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
+`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The provider omits `pathToClaudeCodeExecutable`, so the SDK selects Claude Code 2.1.220 from the matching OS, CPU, and Linux-libc platform package in its own optional dependency closure. The provider does not resolve or fall back to a host `claude`; an omitted, unsupported, missing, or damaged platform payload fails the first delegation at the SDK startup boundary. The provider uses the official `query()` entrypoint and passes the SDK's native `claude` or `claude.exe` command, arguments, cwd, environment, and forwarded signal from `spawnClaudeCodeProcess` to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
 
-The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own.
+The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own.
 
-The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail.
+The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact.
 
-Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable.
+Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic.
 
 The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit.
 
 ## Distribution and evidence
 
-Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
+Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
 
-The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
+The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence.
 
 The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
 
-The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`.
+The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product.
 
 The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
 
@@ -78,7 +78,7 @@ The project owner's distribution authorization is scoped to the official `@anthr
 
 **A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly.
 
-**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service.
+**A model-visible product selector.** Product availability, instance configuration, and authentication are deployment facts. Profile-bound tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service.
 
 **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture.
 
@@ -88,8 +88,8 @@ The project owner's distribution authorization is scoped to the official `@anthr
 
 ## Consequences
 
-Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence.
+Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence.
 
-Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
+Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
 
 Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe.

+ 15 - 15
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md

@@ -12,12 +12,12 @@ Status: implemented
 
 ## 决策
 
-harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。
+harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
 
 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。
 
 ```text
-fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process
+configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process
   foreground <- final product outcome
   background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls
   both -> provider disposal -> dsh-subprocess -> whole-tree exit
@@ -34,39 +34,39 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product
 
 ## Codex 提供方
 
-`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。
+`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,解析锁定的 `@openai/codex@0.147.0` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。
 
 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
 
-`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。
+`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。
 
 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
 
-若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。
+若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。
 
 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
 
 ## Claude Code 提供方
 
-`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
+`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。提供方会省略 `pathToClaudeCodeExecutable`,因此 SDK 会从自己的 optional dependency 闭包中,按操作系统、CPU 与 Linux libc 选择携带 Claude Code 2.1.220 的匹配平台包。提供方既不会解析也不会回退宿主 `claude`;省略 optional dependency、不受支持的平台,以及缺失或损坏的平台载荷,都会在第一次委派的 SDK 启动边界失败。提供方使用官方 `query()` 入口点,并把 SDK 的 `spawnClaudeCodeProcess` 给出的原生 `claude` 或 `claude.exe` 命令、参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
 
-公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。
+公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。
 
-只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。
+只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。
 
-启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。
+启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。
 
 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。
 
 ## 分发与证据
 
-每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
+每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
 
-Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
+Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 schema、失败、进程结果与最终呈现证据。
 
 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
 
-Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。
+Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。
 
 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
 
@@ -78,7 +78,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl
 
 **共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。
 
-**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。
+**面向模型的产品选择器。** 产品可用性、实例配置和身份验证属于部署事实。由 Profile 绑定的工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。
 
 **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。
 
@@ -88,8 +88,8 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl
 
 ## 后果
 
-用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
+用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
 
-每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
+每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
 
 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md
-2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb
-2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e
+2026-08-08-pi-ai-per-model-reasoning-declarations.md: 0e8d5c3ca4017e89332f6b22e4eb0a06062918e6
+2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: c4c060b21c8b7ee5b11b5e98c8d7da0ba6c032fc

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md

@@ -14,7 +14,7 @@ Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`
 
 `PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly.
 
-`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record<UpstreamUnion, true>` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1).
+`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. `thinkingFormat` is pinned to pi-ai's union through a `Record<UpstreamUnion, true>` drift gate, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). Which fields `compat` carries, which protocols take each of them, and how an unreadable key is refused are owned by [[2026-08-18-pi-ai-wire-compat-surface]]; the two-level resolution order above is what that surface generalizes.
 
 `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model).
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 `PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。
 
-`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record<UpstreamUnion, true>` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。
+`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。`thinkingFormat` 经 `Record<UpstreamUnion, true>` 漂移门禁钉在 pi-ai 的联合类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。`compat` 承载哪些字段、每个字段由哪些协议接受、以及无法读取的键如何被拒绝,归 [[2026-08-18-pi-ai-wire-compat-surface]] 所有;上面这条两级解析顺序正是该面所推广的东西。
 
 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
-2026-08-11-workspace-sidebar-order-and-folding.md: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619
+2026-08-11-workspace-sidebar-order-and-folding.md: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md

@@ -28,7 +28,7 @@ The combined view menu offers **Manual** and **Last updated** in grouped and fla
 
 Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker.
 
-Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances.
+Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query; while the rail search gesture is still in flight the outside-click listener stays unmounted ([rail-search self-dismissal](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)). Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md

@@ -28,7 +28,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
 
-搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
+搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留;轨道搜索手势仍在进行期间,外部点击监听器保持未挂载([轨道搜索自我收起](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md))。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.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 .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md
+2026-08-12-open-ready-web-ui.md: 075082aff6c0b811de3c6750637cb823fe83be61
+2026-08-12-open-ready-web-ui.zh.md: de73ab0c8574ff924229b54c2aedf41899fedee8

+ 35 - 0
.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md

@@ -0,0 +1,35 @@
+# Agent Note: `dsh web` opens its ready page
+
+Status: implemented
+
+English | [中文](2026-08-12-open-ready-web-ui.zh.md)
+
+## Problem
+
+`dsh web` bound the HTTP server and printed its canonical local URL, but left the user to copy that URL into a browser even though the root README described the command as opening the Web UI. A browser handoff also cannot run at the server's bind callback alone: the API routes, browser plugin roster, and static fallback may still be mounting, so the first page request could observe an incomplete application that the process is about to reject.
+
+## Decision
+
+The Web app's command provider resolves `openBrowser: true` for an ordinary invocation and `false` for `--no-open`. The bundle passes that value into its `web-runtime` row; deployments may still replace the complete row config explicitly. The runtime samples inherited `SSH_CONNECTION` and `SSH_TTY` once during activation and suppresses browser handoff when either is non-empty, because the process then serves remote host loopback while the SSH client or editor owns the user's local forwarded address.
+
+The Web runtime treats URL printing and browser opening as separate actions at one readiness point. It waits for the complete Loader tree to settle and confirms that `webServer` is still live, then prints the configured URL line and, outside SSH, prints `dsh web: opening the default browser; pass --no-open to disable` immediately before handing the canonical loopback URL to the operating system's default browser. An SSH launch keeps the host URL line so the operator can identify the remote port, but cannot derive or open the forwarding owner's local address. A deployment that explicitly binds all interfaces still opens loopback locally while the printed LAN URL remains informational; the CLI rejects `--host 0.0.0.0`. `openBrowser` and `printUrl` can be disabled independently.
+
+The handoff uses the maintained `open` package for macOS, Windows, Linux, containers, and WSL. A short-lived Node helper invokes that package with the canonical scrubbed child environment, so Harness credentials and `DSH_*` state do not reach the operating-system launcher or a newly started browser. `BROWSER` is a launch-only command selector: app boot rejects it in a discovered `.env`, while only an inherited value can reach a compatible opener path that honors the variable. On Windows the helper waits for the short-lived PowerShell launcher to exit because `open` resolves when that process spawns, before it has handed the URL to the shell; other platforms stop after the opener accepts spawn. The runtime never waits for the browser to exit. The parent reads helper stderr so a failure writes one English diagnostic with the specific reason and manual URL to stderr without disposing the already-ready server; a later browser exit is outside the handoff result.
+
+Unit coverage pins command defaults, `--no-open`, SSH suppression, readiness ordering, teardown and failure suppression, helper outcomes, stderr reason propagation, the Windows launcher lifetime, the scrubbed helper environment, the inherited-only `BROWSER` rule, the pre-handoff opt-out status, and the reason-bearing non-fatal diagnostic. A real Loader composition binds an OS-assigned port, serves the actual static fallback, replaces only the operating-system handoff, and requests the handed-off URL immediately to prove it is already reachable. Assembled keyless snapshots run the built `dsh web` command locally, with a failing opener, with VS Code plus SSH markers, and from a project that declares `BROWSER`: the local case verifies that the handed-off page is the printed, reachable page containing the boot manifest while credential and Harness-state variables are absent from the opener; the failure case verifies the stderr reason and manual URL after readiness; the remote case verifies that the host URL remains visible without a browser launch; the file-layer command case fails before readiness or handoff. Repository browser and packaging tests pass `--no-open` because they own their browser or run unattended.
+
+## Alternatives considered
+
+**Open from the CLI launcher** — rejected because the launcher deliberately knows only profile selection and cannot derive the OS-assigned port or the app-owned Loader settlement point without reversing the app-owned command-line decision.
+
+**Open from `dsh-host-webserver` when its socket binds** — rejected because that package is a generic route carrier with no shell or frontend knowledge, and socket readiness precedes application readiness.
+
+**Infer whether to open from TTY, CI, editor, display, container, or WSL variables** — rejected because those signals do not establish a host/browser split and misclassify detached terminals and desktop launches. Non-empty `SSH_CONNECTION` or `SSH_TTY` is narrower evidence: it identifies a remote host whose loopback URL is not the forwarding owner's local URL. The default plus explicit `--no-open` remains stable for non-SSH launches.
+
+**Require Enter before opening the browser** — rejected for the local default because it turns ordinary server startup into a second stdin-owned interaction and excludes desktop or supervised launches with no usable terminal. `--no-open` remains the explicit opt-out for a caller that owns the browser or wants a server only.
+
+**Hand-roll platform commands** — rejected because URL opening has distinct macOS, Windows, Linux, container, and WSL behavior. The maintained dependency owns those platform branches while this package retains only readiness and failure semantics.
+
+## Consequences
+
+An ordinary local `dsh web` invocation announces the automatic handoff and its `--no-open` opt-out, then opens one ready page without making the generic HTTP carrier desktop-aware or exposing its ambient credentials to the desktop launcher. An SSH invocation prints the remote host URL but leaves opening the forwarded local address to the SSH client or editor. A discovered `.env` that sets `BROWSER` fails launch instead of selecting an executable; a platform opener that honors the variable can read it only when the operator exports it in the launching shell. Other unattended consumers must pass `--no-open`; a handoff failure writes its reason and manual URL to stderr while preserving the usable server. The Web app gains the locked `open` dependency, the shared subprocess environment scrubber, and the opener's transitive platform helpers; it does not own, wait for, or terminate the browser after the operating-system handoff succeeds.

+ 35 - 0
.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: `dsh web` 打开已就绪页面
+
+Status: implemented
+
+[English](2026-08-12-open-ready-web-ui.md) | 中文
+
+## Problem
+
+`dsh web` 会绑定 HTTP 服务器并打印规范本地 URL,但仍要求用户把 URL 复制到浏览器,尽管根 README 已把该命令描述为会打开 Web UI。浏览器交接也不能只以服务器绑定回调为时机:API 路由、浏览器插件名录和静态回退可能仍在挂载,第一次页面请求可能看到一个尚未完整且即将被进程判定为启动失败的应用。
+
+## Decision
+
+Web 应用的命令提供方为普通调用解析出 `openBrowser: true`,为 `--no-open` 解析出 `false`。组合包把该值传给自己的 `web-runtime` 行;部署仍可显式替换该行的完整配置。运行时在激活期间对继承的 `SSH_CONNECTION` 与 `SSH_TTY` 采样一次,只要其中一项非空就会跳过浏览器交接,因为此时进程提供的是远端宿主机 loopback,而用户的本地转发地址由 SSH 客户端或编辑器持有。
+
+Web 运行时把 URL 打印与浏览器打开作为同一就绪点上的两个独立动作。它等待完整 Loader 配置树结算,并确认 `webServer` 仍在线,然后打印已配置的 URL 行;非 SSH 环境下还会在把规范 loopback URL 交给操作系统默认浏览器之前立即打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。SSH 启动会保留宿主机 URL 行,以便操作者识别远端端口,但进程无法推导或打开转发持有方的本地地址。部署显式绑定所有网络接口时,本机仍打开 loopback,打印出的 LAN URL 只用于告知;CLI 会拒绝 `--host 0.0.0.0`。`openBrowser` 与 `printUrl` 可以分别关闭。
+
+交接使用维护中的 `open` 包处理 macOS、Windows、Linux、容器和 WSL。一个短生命周期 Node helper 使用规范的脱敏子进程环境调用该包,因此 Harness 凭据和 `DSH_*` 状态不会进入操作系统启动器或新启动的浏览器。`BROWSER` 是只能来自启动环境的命令选择器:应用启动过程会拒绝被发现的 `.env` 中的该变量,只有继承值才能抵达会读取该变量的兼容 opener 路径。在 Windows 上,helper 会等待短生命周期 PowerShell launcher 退出,因为 `open` 会在该进程 spawn 时、尚未把 URL 交给 shell 之前返回;其他平台则在 opener 接受 spawn 后结束。运行时绝不等待浏览器退出。父进程会读取 helper stderr,因此失败时只向 stderr 写入一条包含具体原因和手动访问 URL 的英文诊断,不会 dispose 已就绪的服务器;浏览器之后退出不属于本次交接结果。
+
+单元覆盖钉住命令默认值、`--no-open`、SSH 抑制、就绪顺序、资源释放与失败抑制、helper 结果、stderr 原因传播、Windows launcher 生命周期、helper 的脱敏环境、`BROWSER` 仅可继承的规则、交接前 opt-out 提示以及包含原因的非致命诊断。真实 Loader 组合会绑定由操作系统分配的端口、提供实际静态回退,只替换操作系统交接,并立即请求被交接的 URL,以证明页面此时已可访问。无密钥的整体快照会分别在本机环境、opener 失败环境、带 VS Code 与 SSH 标记的环境,以及声明了 `BROWSER` 的项目中运行构建后的 `dsh web` 命令:本机用例验证被交接的页面就是打印出的、已可访问且包含启动清单的页面,同时 opener 中不存在凭据与 Harness 状态变量;失败用例验证就绪后的 stderr 原因和手动 URL;远端用例验证宿主机 URL 仍可见,但不会启动浏览器;文件层命令用例则在就绪或交接前失败。仓库内浏览器与打包测试会传入 `--no-open`,因为它们自行持有浏览器或在无人值守环境运行。
+
+## Alternatives considered
+
+**从 CLI 启动器打开** — 否决,因为启动器刻意只了解 profile 选择,无法取得操作系统分配的端口或应用自有的 Loader 结算点;让它了解这些事实会推翻应用自有命令行决策。
+
+**在 `dsh-host-webserver` 绑定 socket 时打开** — 否决,因为该包是不了解 shell 与前端的通用路由载体,而且 socket 就绪早于应用就绪。
+
+**根据 TTY、CI、编辑器、显示、容器或 WSL 环境变量推断是否打开** — 否决,因为这些信号不能证明宿主机与浏览器分离,并会误判分离终端和桌面启动。非空的 `SSH_CONNECTION` 或 `SSH_TTY` 是更窄的证据:它表明远端宿主机 loopback URL 并不是转发持有方的本地 URL。非 SSH 启动仍保持默认打开并提供显式 `--no-open`。
+
+**打开浏览器前要求按下 Enter** — 不作为本机默认行为,因为它会把普通服务器启动变成由 stdin 持有的第二次交互,并排除没有可用终端的桌面启动或受监督启动。调用方自行持有浏览器或只需要服务器时,仍通过 `--no-open` 显式退出。
+
+**手写各平台命令** — 否决,因为 URL 打开在 macOS、Windows、Linux、容器和 WSL 上各有不同。维护中的依赖持有这些平台分支,本包只保留就绪与失败语义。
+
+## Consequences
+
+普通的本机 `dsh web` 调用会先公告自动交接及其 `--no-open` 退出方式,再打开一个已就绪的页面,同时不会让通用 HTTP 载体感知桌面环境,也不会向桌面启动器暴露环境凭据。SSH 调用会打印远端宿主机 URL,但由 SSH 客户端或编辑器负责打开转发后的本地地址。被发现的 `.env` 如果设置 `BROWSER`,启动就会失败,而不是选择一个可执行文件;会读取该变量的平台 opener 只有在操作者从启动 shell 中 export 时才能取得它。其他无人值守消费方必须传入 `--no-open`;交接失败时会向 stderr 写入原因与手动访问 URL,同时保留可用服务器。Web 应用新增锁定的 `open` 依赖、共享子进程环境脱敏器及 opener 的传递平台辅助包;操作系统交接成功后,本应用不持有、不等待也不终止浏览器。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md
-2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566
-2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7
+2026-08-12-product-subagent-one-shot-background-tasks.md: 5e9522f6fac6eadb874ba2d1d4f45100f962b9e2
+2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 9685701cbe07df226357e9830a875e3928ae039d

+ 5 - 3
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md

@@ -12,7 +12,9 @@ Exposing background execution must not add a product session, product-specific j
 
 ## Decision
 
-Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion.
+Production `dsh` does not install the optional product providers. A Profile that opts in installs the needed `dsh-subagent-codex` or `dsh-subagent-claude-code` packages and mounts the required provider instances on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion.
+
+The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for either product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances.
 
 The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production.
 
@@ -33,7 +35,7 @@ product tool call
 
 | Fact or resource | Owner | Product-tool responsibility | Observable result |
 | --- | --- | --- | --- |
-| Product provider installation and registration | Explicit Profile | Install the optional provider package and mount it once on the host plane | The provider name is available without adding its package to every production `dsh` install |
+| Product provider installation and registration | Explicit Profile | Install the optional provider package and mount the required named instances on the host plane | The provider names are available without adding the package to every production `dsh` install |
 | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool |
 | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id |
 | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent |
@@ -41,7 +43,7 @@ product tool call
 
 ## Published composition
 
-The production base keeps both optional product providers out of its dependency closure. An opting-in Profile installs and mounts either or both providers once on the host plane. Each full preset keeps both product-tool rows disabled and contributes the generic Job controls to its own agent scope, while the base host owns the shared Job registry. A user copies a preset and removes `disabled` from the matching product rows after the Profile provider is present; no product process starts during composition.
+The production base keeps both optional product providers out of its dependency closure. An opting-in Profile installs the needed packages and mounts the required provider instances on the host plane. Each full preset keeps both product-tool rows disabled and contributes the generic Job controls to its own agent scope, while the base host owns the shared Job registry. A user copies a preset and removes `disabled` from the matching product rows after the Profile providers are present; no product process starts during composition.
 
 A standalone custom composition that enables one-shot background execution must provide the product provider plus the complete generic Job capability: `dsh-jobs-local` as the Job provider and `dsh-tool-jobs` as the model-facing consumer. A Profile based on `dsh-base` already has the Job capability and adds only the optional product provider before enabling the preset tool row. A product tool without the Job runtime can still execute in the foreground, but an explicit background request fails the existing Job preflight instead of publishing an uncollectable id.
 

+ 5 - 3
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md

@@ -12,7 +12,9 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回
 
 ## 决策
 
-生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。
+生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装所需的 `dsh-subagent-codex` 或 `dsh-subagent-claude-code` 包,并在 host plane(宿主平面)挂载所需的提供方实例。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。
+
+[命名实例决策](2026-08-18-product-subagent-named-instances.md)允许两个产品分别拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。
 
 [通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。
 
@@ -33,7 +35,7 @@ product tool call
 
 | 事实或资源 | 责任方 | 产品工具职责 | 可观察结果 |
 | --- | --- | --- | --- |
-| 产品提供方安装与登记 | 显式 Profile | 安装可选提供方包,并在 host plane 挂载一次 | 提供方名称可用,但不会让每次生产 `dsh` 安装都包含该包 |
+| 产品提供方安装与登记 | 显式 Profile | 安装可选提供方包,并在 host plane 挂载所需的命名实例 | 提供方名称可用,但不会让每次生产 `dsh` 安装都包含该包 |
 | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 |
 | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id |
 | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 |
@@ -41,7 +43,7 @@ product tool call
 
 ## 发布组装
 
-生产 base 不让两个可选产品提供方进入依赖闭包。选择启用产品集成的 Profile 会在 host plane 安装并挂载任一或两个提供方。每个完整 preset 让两个产品工具行保持禁用,并把通用 Job 控制工具贡献到自身 agent 作用域;base host 负责共享 Job 注册表。Profile 提供方存在后,用户复制一个 preset,再从对应产品行删除 `disabled`;组装期间不会启动产品进程。
+生产 base 不让两个可选产品提供方进入依赖闭包。选择启用产品集成的 Profile 会安装所需包,并在 host plane 挂载所需的提供方实例。每个完整 preset 让两个产品工具行保持禁用,并把通用 Job 控制工具贡献到自身 agent 作用域;base host 负责共享 Job 注册表。Profile 提供方实例存在后,用户复制一个 preset,再从对应产品行删除 `disabled`;组装期间不会启动产品进程。
 
 独立自定义组装若启用 one-shot 后台执行,就必须同时提供产品提供方与完整通用 Job 能力:由 `dsh-jobs-local` 充当 Job 提供方,由 `dsh-tool-jobs` 充当面向模型的消费方。基于 `dsh-base` 的 Profile 已具备 Job 能力,只需在启用 preset 工具行前新增可选产品提供方。没有 Job 运行时的产品工具仍可在前台执行,但显式后台请求会在现有 Job 预检中失败,不会发布无法收集的 id。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
-2026-08-15-product-subagent-noninteractive-permissions.md: df1f0d9939e951f16070729615a3779f1f7c2ddc
-2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883
+2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd
+2026-08-15-product-subagent-noninteractive-permissions.zh.md: 6f254930151abce23f04de4f354bf57ad81bba61

+ 4 - 4
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md

@@ -44,9 +44,9 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura
 
 ### Failure diagnostic
 
-`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character.
+`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field.
 
-Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
+Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
 
 The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior.
 
@@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia
 
 ## Verification
 
-Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter.
+Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter.
 
 ## Alternatives considered
 
@@ -83,6 +83,6 @@ Package tests pin every allowed and rejected Config value, the exact SDK and app
 
 Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences.
 
-Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement.
+Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. The same field can also carry the separately owned structured failure facts. It can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound the complete text before result settlement.
 
 The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.

+ 4 - 4
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md

@@ -44,9 +44,9 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
 
 ### 失败诊断
 
-`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。
+`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。
 
-每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
+每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
 
 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
 
@@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
 
 ## Verification
 
-包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。
+包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。
 
 ## Alternatives considered
 
@@ -83,6 +83,6 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
 
 Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
 
-权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。
+权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。同一字段还可以承载由另一项决策负责的结构化失败事实。它可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前对完整文本完成脱敏和限长。
 
 本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff