Browse Source

Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery

# Conflicts:
#	docs/subsystems/session-projection.i18n.yaml
#	docs/subsystems/session-projection.md
#	docs/subsystems/session-projection.zh.md
#	packages/context/agent-instructions/tests/agent-instructions.spec.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/session/session-projection/README.i18n.yaml
#	packages/session/session-projection/README.md
#	packages/session/session-projection/README.zh.md
_Kerman 4 weeks ago
parent
commit
37c5dc4b35
100 changed files with 1251 additions and 232 deletions
  1. 2 2
      .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml
  2. 2 1
      .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md
  3. 2 1
      .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md
  4. 2 2
      .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml
  5. 1 0
      .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md
  6. 1 0
      .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md
  7. 2 2
      .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml
  8. 1 0
      .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md
  9. 2 1
      .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md
  10. 2 2
      .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml
  11. 1 0
      .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md
  12. 1 0
      .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md
  13. 12 0
      .agents/notes/archived/manifest.json
  14. 2 2
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
  15. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
  16. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
  18. 4 4
      .agents/notes/implemented/architecture/2026-06-20-branded-ids.md
  19. 4 4
      .agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml
  21. 1 1
      .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
  22. 1 1
      .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md
  23. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  24. 0 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  25. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  26. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  27. 18 33
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  28. 18 33
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  29. 0 30
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  30. 0 30
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  31. 2 2
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml
  32. 6 2
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md
  33. 6 2
      .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md
  34. 2 2
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml
  35. 8 8
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
  36. 8 8
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md
  37. 3 3
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml
  38. 61 0
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
  39. 61 0
      .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md
  40. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml
  41. 2 1
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
  42. 2 1
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md
  43. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
  44. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
  45. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
  46. 6 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml
  47. 70 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
  48. 70 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md
  49. 6 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml
  50. 33 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md
  51. 33 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md
  52. 6 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml
  53. 75 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md
  54. 75 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md
  55. 2 2
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml
  56. 1 1
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md
  57. 1 1
      .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md
  58. 2 2
      .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml
  59. 1 1
      .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
  60. 1 1
      .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md
  61. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml
  62. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
  63. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md
  64. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml
  65. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
  66. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md
  67. 6 0
      .agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.i18n.yaml
  68. 32 0
      .agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md
  69. 32 0
      .agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.zh.md
  70. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.i18n.yaml
  71. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md
  72. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md
  73. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml
  74. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md
  75. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md
  76. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml
  77. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md
  78. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md
  79. 2 2
      .agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
  80. 1 1
      .agents/notes/implemented/feature/2026-07-06-approval-seam.md
  81. 1 1
      .agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
  82. 2 2
      .agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
  83. 3 3
      .agents/notes/implemented/feature/2026-08-05-agent-teams.md
  84. 3 3
      .agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
  85. 6 0
      .agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml
  86. 43 0
      .agents/notes/implemented/feature/2026-08-06-agent-teams-web.md
  87. 43 0
      .agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md
  88. 6 0
      .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml
  89. 35 0
      .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md
  90. 35 0
      .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md
  91. 6 0
      .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml
  92. 31 0
      .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md
  93. 31 0
      .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md
  94. 2 2
      .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml
  95. 1 1
      .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md
  96. 1 1
      .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md
  97. 6 0
      .agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.i18n.yaml
  98. 31 0
      .agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md
  99. 31 0
      .agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.zh.md
  100. 6 0
      .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml → .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.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-07-31-composer-text-layers-share-one-scrollport.md
-2026-07-31-composer-text-layers-share-one-scrollport.md: 4989ab3bcedcf0ea29b95b565b2e0756ab9d5b62
-2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 95b6d3055775f733f6c700823ce307869d1c0961
+2026-07-31-composer-text-layers-share-one-scrollport.md: eb50673bb5fac50e12b0325c22c67072e130efb6
+2026-07-31-composer-text-layers-share-one-scrollport.zh.md: f0af130d34682fcdfe145eb73b18187ca316c0d2

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md → .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md

@@ -1,6 +1,7 @@
 # Agent Note: The composer's two text layers share one scrollport
 
 Status: implemented
+Archived: 2026-08-20
 
 English | [中文](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)
 
@@ -48,7 +49,7 @@ Revealing the caret is the one thing that now depends on the browser rather than
 
 **Scroll both layers from JavaScript, with the textarea `overflow: hidden` and a wheel handler assigning both offsets in one task.** No divergence during wheel gestures, since nothing scrolls without us. Rejected because it replaces native scrolling — momentum, trackpad rubber-banding, scrollbar dragging, keyboard scrolling — with a hand-written approximation, and the caret-reveal path (the browser setting the textarea's own offset) still lands asynchronously.
 
-**Keep the cap on the mirror and just wrap the existing structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything.
+**Keep the cap on the mirror and just wrap today's structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything.
 
 **Give the backdrop `overflow: auto` and let it scroll itself.** It would then have an offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface.
 

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md → .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: composer 的两层文本共用同一个滚动容器
 
 Status: implemented
+Archived: 2026-08-20
 
 [English](2026-07-31-composer-text-layers-share-one-scrollport.md) | 中文
 
@@ -48,7 +49,7 @@ Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的
 
 **两层都由 JavaScript 驱动滚动:textarea 设 `overflow: hidden`,滚轮处理器在同一个任务里给两个偏移赋值。** 滚轮手势期间不会分离,因为没有我们就没有东西会滚动。被否决,是因为它用手写近似替换了原生滚动——惯性、触控板回弹、拖拽滚动条、键盘滚动——而且光标回视路径(浏览器设置 textarea 自己的偏移)仍然是异步落地的。
 
-**把上限留在镜像层上,只在既有结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。
+**把上限留在镜像层上,只在今天的结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。
 
 **给 backdrop 加 `overflow: auto`,让它自己滚动。** 那样它就有了一个自己的偏移需要保持同步,即同一个问题再加一条画在输入框上的滚动条。backdrop 是 textarea 的投影,不是一个可独立导航的界面。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml → .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.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-13-safari-textarea-soft-wrap-reflow.md
-2026-08-13-safari-textarea-soft-wrap-reflow.md: fb264a8e6fbe24369584f2427bbb0c462b450ecf
-2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: f4cdf9b38ca8ca093cf388973d40e652d76a2f27
+2026-08-13-safari-textarea-soft-wrap-reflow.md: 45cb3f39c50c72b44b8ae952ce3a861210e9f00a
+2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 37409b010c0009edf3c944076ba8c3db1b140de9

+ 1 - 0
.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md → .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md

@@ -1,6 +1,7 @@
 # Agent Note: Safari textarea soft-wrap shrink recovery
 
 Status: implemented
+Archived: 2026-08-20
 
 English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md)
 

+ 1 - 0
.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md → .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: Safari textarea 软换行收缩恢复
 
 Status: implemented
+Archived: 2026-08-20
 
 [English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml → .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.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-20-composer-edit-range-from-selection.md
-2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0
-2026-08-20-composer-edit-range-from-selection.zh.md: 86e65e4567c6d061b458e77be856aa1942cf0fe3
+2026-08-20-composer-edit-range-from-selection.md: 46eaa0add61bdab9fdcb4fcfd0ec08b44481126d
+2026-08-20-composer-edit-range-from-selection.zh.md: 73a3903ad6c7c0aad55a35aacc5e1396084b6e7a

+ 1 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md → .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md

@@ -1,6 +1,7 @@
 # Agent Note: Composer edits carry the range they applied to
 
 Status: implemented
+Archived: 2026-08-20
 
 English | [中文](2026-08-20-composer-edit-range-from-selection.zh.md)
 

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md → .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 输入框的编辑自带它所作用的范围
 
 Status: implemented
+Archived: 2026-08-20
 
 [English](2026-08-20-composer-edit-range-from-selection.md) | 中文
 
@@ -14,7 +15,7 @@ Status: implemented
 
 此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。
 
-这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.zh.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。
+这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。
 
 ## 决策
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml → .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.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-20-composer-reference-decoration-keys.md
-2026-08-20-composer-reference-decoration-keys.md: db565e89e1c8addcd1669e295b3be4433083d6bd
-2026-08-20-composer-reference-decoration-keys.zh.md: 7af596189ca90890cb81d099f77ae86e2a62e532
+2026-08-20-composer-reference-decoration-keys.md: 316d45841c658d3d65246fb7425526b10e2f6bf3
+2026-08-20-composer-reference-decoration-keys.zh.md: 90ac7c8011bb7f7f45312c25ffccb7dbbbb70505

+ 1 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md → .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md

@@ -1,6 +1,7 @@
 # Agent Note: Composer reference decorations key by draft-order ordinal
 
 Status: implemented
+Archived: 2026-08-20
 
 English | [中文](2026-08-20-composer-reference-decoration-keys.zh.md)
 

+ 1 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md → .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 输入框引用装饰按草稿顺序序号取 key
 
 Status: implemented
+Archived: 2026-08-20
 
 [English](2026-08-20-composer-reference-decoration-keys.md) | 中文
 

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

@@ -88,6 +88,9 @@
     "bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17",
     "bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f",
     "bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189",
+    "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml": "sha256:36fc626dcbf1e276a36713e85860752cef0b36a5881f2493bdeb6d9654621b02",
+    "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md": "sha256:3ece47f91ee5f7354ef73ca0562aafeec19f89a19d9a64c9e0a565fe6d8c2049",
+    "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md": "sha256:578e772ecbc1a4a39bddbb0a9f3fdbf67c70ff8c8952cc80d2caa3d8b76e9b36",
     "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef",
     "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43",
     "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1",
@@ -112,6 +115,15 @@
     "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588",
     "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723",
     "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1",
+    "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml": "sha256:23c26323f92a2172fd30fd724177b84d012cf4e18f1eff79ab092d4e0687ad4e",
+    "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md": "sha256:f9edea8501df36d444d84790ef9b0ae5bed4283a9cc5a5403800b80908f3db39",
+    "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md": "sha256:ddcf6bb67823d19dc98964fcb9d663a10bcf663573394cfd7b235d9801d6525a",
+    "bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml": "sha256:c91ed2d9cb2a9891011fcbbe46885bc1808e36831279d56bc5cea0b9b1515b55",
+    "bug-fix/2026-08-20-composer-edit-range-from-selection.md": "sha256:e36920dee0318a35eaf49bff8c574698902f3be51d6115d40a3f97fa1436bd47",
+    "bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md": "sha256:41f44adc93797cf9073f19f954b6ac87147a2e6806f1ad051c80c3423f0175ae",
+    "bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml": "sha256:cadf1de336aa2756d1bc1c20c7679449390b0a7a4217fe9602996801b6bc1958",
+    "bug-fix/2026-08-20-composer-reference-decoration-keys.md": "sha256:0093eabd710f10ae1faca53be01c9404a9d63cf6a2cf4dbf226e458e6315e201",
+    "bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md": "sha256:a5fb2a748cf6ff8353d536448a5469e731157ccc2d0bb43210ea5dc44dd8ed31",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16",

+ 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: a31df6a7d16ea7cba649702fdb474dab34533c1b
-2026-06-11-content-block-vocabulary.zh.md: da387b179816cda64791e71ca7affa1fbdfd195b
+2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2
+2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b

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

@@ -25,4 +25,4 @@ In-session context injection (`context/message`) and mid-turn steering originall
 - 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.
+- IDs that cross package boundaries are branded (`ToolCallId`, 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

@@ -25,4 +25,4 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
 - 多模态块只有在适配器、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`)——零运行时开销的名义类型。
+- 跨包边界的 ID 使用品牌类型(`ToolCallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-20-branded-ids.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-20-branded-ids.md
-2026-06-20-branded-ids.md: dda97bbf546ef99083cbe3bd2c7da39070407e04
-2026-06-20-branded-ids.zh.md: 82b79dff9d5018e2ea9f9969148eb25225f6d727
+2026-06-20-branded-ids.md: 6443608c76fe42be74a2b8fe8a27669b09951a49
+2026-06-20-branded-ids.zh.md: f13d999aadf4dba7f2c7d31bb2739deae4a0991f

+ 4 - 4
.agents/notes/implemented/architecture/2026-06-20-branded-ids.md

@@ -6,13 +6,13 @@ English | [中文](2026-06-20-branded-ids.zh.md)
 
 ## Problem
 
-The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker.
+The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker.
 
 **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-job id is a plain `string`: `BashTask.id: string` (`packages/shell/shell/src/types.ts`), carried as `string` through the whole executor seam (`ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/shell/shell/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateJobId`, `assertTaskAccess`, the `job_id` schema arg in `packages/shell/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/shell/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `job_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
 
 The bash **owner token** is the related sub-case: `ShellExecRequest.owner?: string` and `ShellExecSpec.owner: string | undefined` (`packages/shell/shell/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/shell/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md).
 
-**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.
+**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `ToolCallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.
 
 ## Decision
 
@@ -22,7 +22,7 @@ A type-only change. Brands are zero-cost casts; nothing about runtime behavior,
 
 - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/shell/shell/src/types.ts`; type `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.)
 
-- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map<SessionId, Session>`, `Map<SessionId, Agent>`, `get(id: SessionId)`, `Map<CallId, …>`, ACP's `SessionId` surface, and the coordinator's `Map<SessionId, …>`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields.
+- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map<SessionId, Session>`, `Map<SessionId, Agent>`, `get(id: SessionId)`, `Map<ToolCallId, …>`, ACP's `SessionId` surface, and the coordinator's `Map<SessionId, …>`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields.
 
 Illustrative shape (the factory pattern is identical to the three existing brands):
 
@@ -60,7 +60,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
 
 ## Verification
 
-The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts.
+The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`ToolCallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## 问题
 
-harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。
+harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `ToolCallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。
 
 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。
 
 bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。
 
-**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
+**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `ToolCallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
 
 ## 决策
 
@@ -22,7 +22,7 @@ bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 
 
 - **铸造独立的 `OwnerToken` brand。** 在 `packages/shell/shell/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。)
 
-- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`Map<SessionId, Agent>`、`get(id: SessionId)`、`Map<CallId, …>`、ACP 的 `SessionId` surface、协调器的 `Map<SessionId, …>`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
+- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`Map<SessionId, Agent>`、`get(id: SessionId)`、`Map<ToolCallId, …>`、ACP 的 `SessionId` surface、协调器的 `Map<SessionId, …>`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
 
 示意形状(工厂模式与已有的三个 brand 完全一致):
 
@@ -60,7 +60,7 @@ export function OwnerToken(id: string): OwnerToken {
 
 ## 验证
 
-已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。
+已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`ToolCallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.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-08-tool-output-spill-files.md
-2026-07-08-tool-output-spill-files.md: e14607e388c634c4e2679c993c1b720be0a3a9f3
-2026-07-08-tool-output-spill-files.zh.md: 372c9c6cadf3cd64c3de97a8c305b8909f03caab
+2026-07-08-tool-output-spill-files.md: 915e22f1245adb6f7cfc7d358e9d5802531bab63
+2026-07-08-tool-output-spill-files.zh.md: 8d08b05483a302f4188506531da6f507931bea9c

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md

@@ -35,7 +35,7 @@ interface SpillStore {
 
 interface SpillSource {
   toolName: string
-  callId: CallId
+  callId: ToolCallId
   label: string
 }
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md

@@ -35,7 +35,7 @@ interface SpillStore {
 
 interface SpillSource {
   toolName: string
-  callId: CallId
+  callId: ToolCallId
   label: string
 }
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc
-2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf
+2026-07-23-client-plugin-loading-model.md: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b
+2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2

File diff suppressed because it is too large
+ 0 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
 浏览器侧,驱动插件每帧重载一个插件,串行执行:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 1dea7fcff9882f701a3335687c8a2ab8a47e2dfb
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: dda9a43c41fc9bec4c26b19c2349f07ec4fc6a31
+2026-07-25-web-input-machine-and-slash-pipeline.md: 3508de5e8a3980a87c344c5b76c060f6119ee686
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: eebfdae780157dfd0dace1386169c5fee8c1d564

+ 18 - 33
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-07-25-web-input-machine-and-slash-pipeline.zh.md)
 
-> Scope: the input state machine (the occurrence table + claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-input-trigger), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / session-maybe and blank entity model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory.
+> Scope: the input state machine (the claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-input-trigger), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / session-maybe and blank entity model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory. The editing surface half of the original decision — the textarea + occurrence table, the self-managed undo log, the paste-match pipeline, and the backdrop decorations — was superseded by [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md); this note remains the owner of the machine's submit plane, the bail-event contract, the trigger pipeline, and the composer slots.
 
 ## Problem
 
@@ -21,32 +21,17 @@ Hard constraints: components mount through slots only; presentation artifacts ne
 
 ## Decision
 
-### The input state machine (`InputMachine`)
+### The input state machine (now `SubmitMachine`)
 
 A pure state machine, events in / effects out, clock injected. Four phases (plain / adjudicating / claimed / submitting). Command mode is **never derived from the draft**; the pick paths establish it explicitly at discrete moments; the claim is watched by `draft.startsWith(token)`, with a backspace break releasing automatically; the claim shape is `{token, hint?}` (hint feeds ghost text).
 
-The event surface (`dispatch(ev)` is the single write entry; one transaction per event):
+The machine's editing half — the occurrence table, the self-managed undo log, the paste-match attempt pipeline, and `set-invalid` — moved into the Lexical document tree and its history ([the Lexical composer note](2026-08-20-web-composer-lexical-editor.md) owns document identity, position mapping, and the three projections). What remains here is the submit plane, unchanged in semantics:
 
-- `draft-changed {draft, editRange?}` — the textarea's full draft; editRange narrows the occurrence-shift computation, defaulting to a shared prefix/suffix scan.
-- `newline {selection}` — the Ctrl+Enter line break (not via the browser's execCommand: under self-managed undo a browser write forks two histories).
-- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}` — the machine side of the three bail events; span CAS = draftRev equality.
-- `set-invalid {invalidIds}` — the style bit for owner-resolution results (not a transaction).
-- `undo` / `redo` — the self-managed transaction log (a ring of 100; single-character typing merges within injected-clock windows; a successful submit clears the log).
-- `paste-begin {text, selection, components?, generation?}` — the paste plus hot-snapshot synchronously matched components in one transaction (one Undo returns to before the paste); opens a PasteMatchAttempt.
-- `paste-upgrade {attemptId, span, reference}` — an asynchronous match upgrade as its own transaction (Undo in two steps); the attempt stays current, and insertedRange shrinks with each upgrade.
-- `invalidate-paste` — attempt-ending gestures observed at the DOM layer (caret/selection operations and the like).
-- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires).
+- `draft-changed {draft}` — the clipboard projection of the editor document, feeding the claim watch.
+- `claim {claim}` / `release` and the span-CAS rewrite events applied at the editor layer — the machine side of the bail events; span CAS = draftRev equality.
+- `enter {draft}` / `adjudicated` / `adjudication-failed` / `submit-settled {draft}` / `send-committed` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft prefix; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires).
 
-The effect surface (executed by the shell): `adjudicate` (calls InputTriggerController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `notice`.
-
-The occurrence table and the chip's three projections:
-
-- Each reference occupies one `U+FFFC` in the draft; a table entry is `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`; same-named chips stay independent through occurrenceId.
-- Every edit updates the draft and the table in one transaction: ranges shift; a deletion/replacement intersecting a placeholder acts on the whole chip.
-- The single-character placeholder makes keyboard atomicity mostly hold natively (the caret has no interior position; Backspace / arrow keys / Shift extension natively take the whole chip); a mouse click on a chip goes backdrop hit → whole-chip setSelectionRange.
-- The visual projection = label: the backdrop renders the chip at the placeholder offset (the textarea glyph is invisible), with invalid taking the invalid style.
-- The clipboard/persistence projection = clipboardText: copy/cut expands placeholders inside the selection; the draft-persistence mirror writes the same projection (the chat store always holds plain text; the refresh seed semantics = select-all copy → reopen → paste, with chips degrading to text across a refresh).
-- The model projection = generated per chip at submit through the source's `codec.serialize` (owned by the submit attempt's signal and stale guard; a missing owner / failure / cancel means no send, never a downgrade to `/name`).
+The effect surface (executed by the shell): `adjudicate` (calls InputTriggerController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `commit-draft` (prefix removal with suffix retention), `notice`.
 
 ### Cross-plugin input rewrites: three scoped bail events
 
@@ -56,21 +41,21 @@ The contract is declared in ui-input-trigger (the bottom of the dependency chain
 - `slash/input-insert-reference` `{reference, span}` — reference chip insertion (dispatched by the InputTriggerController).
 - `slash/input-consume-token` `{guard: span | bare-token}` — consuming the command token after business success (dispatched by the downstream command surfaces).
 
-Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer, the asynchronous paste matcher. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts).
+Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts).
 
 ### The slash pipeline (ui-input-trigger: a root `InputTriggerService` + a per-session `InputTriggerController`)
 
 A trigger/menu/pick pipeline with zero knowledge of "commands":
 
 - The service holds only the source registry (`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
-- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
+- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
 - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
 
 ### hub / facade: the resident shell and the strict-session input body
 
 - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation.
 - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears.
-- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
+- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
 - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
 - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
 - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
@@ -82,14 +67,14 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 
 - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
 - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
-- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
+- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
 - Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
-- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
+- Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
 
 ### Per-session provide contributions and the private keyboard surface
 
 - ui-conversation (the hub doubling as a contributor) supplies through `sessions.provide` the `'input'` hook (machine state + the queue overlay) plus the `inputActions` prop (`setDraft`/`submit`, stable void callbacks).
-- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary.
+- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (`ComposerKeyboard`: the shell-owned editor, arbitrate/space/paste/dismissPopup/caretSpan — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary.
 
 ### The slot system
 
@@ -97,7 +82,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 
 - `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport.
 - `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches.
-- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival.
+- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking composer-surface DOM survival.
 - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId.
 - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
 - `conversation.composer.dock` — the stats band on the composer's top edge.
@@ -116,9 +101,9 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
 | An ActiveCommand intermediate state / a registerMode mode registry / deriving command mode from the draft | Claims are established explicitly by the pick paths — no table, no derivation |
 | Direct bindTarget/bindDraft object wiring | Reverse coupling plus root-singleton cross-session mispairing; scoped bail events preserve dependency inversion with structurally correct routing |
 | A unified slash/input-apply, or eventing everything | Three independent payloads cover the cross-plugin rewrites; asynchronous paths stay registry-based explicit calls |
-| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract |
+| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract — reversed later by [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md) once the styling and identity costs of the textarea stack were measured |
 | Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable |
-| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed |
+| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed — both sides retired with the textarea itself; Lexical's history owns undo now |
 | The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package |
 | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
 | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
@@ -129,7 +114,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
 
 ## Consequences
 
-- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer.
+- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and the composer surface; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and the composer surface. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer.
 - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract.
 - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests.
-- Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream.
+- Known gaps: chip fidelity across refresh has no workstream yet; the subagent reference's model representation awaits its business workstream.

+ 18 - 33
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文
 
-> 范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单流水线(ui-input-trigger)、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)的领地。
+> 范围:输入状态机(claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单流水线(ui-input-trigger)、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)的领地。原决策的编辑面一半——textarea + occurrence 表、自管 undo log、粘贴匹配流水线与 backdrop 装饰——已由 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 取代;本 note 仍拥有状态机的提交平面、bail 事件约定、触发流水线与 composer slot。
 
 ## 问题
 
@@ -21,32 +21,17 @@ Status: implemented
 
 ## 决策
 
-### 输入状态机(`InputMachine`)
+### 输入状态机(现为 `SubmitMachine`)
 
 纯状态机,事件进/效果出,注入时钟。四相 phase(plain / adjudicating / claimed / submitting)。命令态**永不从 draft 推导**,由 pick 路径在离散时刻显式建立;claim 由 `draft.startsWith(token)` 看护、退格破坏自动 release;claim 形状 `{token, hint?}`(hint 供 ghost text)。
 
-事件面(`dispatch(ev)` 单写入口,每个事件一个 transaction)
+机器的编辑面一半——occurrence 表、自管 undo log、粘贴匹配 attempt 流水线与 `set-invalid`——已并入 Lexical 文档树及其 history(文档身份、位置映射与三投影归 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 所有)。留在这里的是语义不变的提交平面
 
-- `draft-changed {draft, editRange?}`——textarea 全量草稿;editRange 缩小 occurrence 平移计算,缺省前后缀共扫。
-- `newline {selection}`——Ctrl+Enter 换行(不经浏览器 execCommand:自管 undo 下浏览器写入会分叉双历史)。
-- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}`——三个 bail 事件的机器侧;span CAS = draftRev 相等。
-- `set-invalid {invalidIds}`——owner resolution 结果的样式位(非 transaction)。
-- `undo` / `redo`——自管 transaction log(容量为 100 的环形缓冲区;单字符打字按注入时钟窗合并;提交成功清 log)。
-- `paste-begin {text, selection, components?, generation?}`——粘贴 + 热快照同步匹配组件同 transaction(Undo 一次回粘贴前);打开 PasteMatchAttempt。
-- `paste-upgrade {attemptId, span, reference}`——异步匹配升级为独立 transaction(Undo 两段);attempt 保持 current,insertedRange 随升级收缩。
-- `invalidate-paste`——DOM 层观察到的 attempt 终结手势(caret/selection 操作等)。
-- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release`——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清稿,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。
+- `draft-changed {draft}`——编辑器文档的剪贴板投影,供 claim 看护。
+- `claim {claim}` / `release` 与在编辑器层应用的 span-CAS 改写事件——bail 事件的机器侧;span CAS = draftRev 相等。
+- `enter {draft}` / `adjudicated` / `adjudication-failed` / `submit-settled {draft}` / `send-committed`——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清除草稿前缀,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。
 
-效果面(shell 执行):`adjudicate`(调 InputTriggerController.adjudicate)、`begin-submit`(claim.submit 事务)、`default-sink`(普通消息,hub 编排)、`notice`。
-
-occurrence 表与 chip 三投影:
-
-- 每颗引用在 draft 中占一个 `U+FFFC`;表项 `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`;同名 chip 因 occurrenceId 独立。
-- 一切编辑同 transaction 更新 draft 与表:区间平移;与占位符相交的删除/替换作用于整颗。
-- 单字符占位使键盘原子性大半原生成立(caret 无内部位;Backspace/方向键/Shift 扩选原生即整颗);鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。
-- 视觉投影 = label:backdrop 在占位符 offset 渲染 chip(textarea 字形不可见),invalid 走失效样式。
-- 剪贴板/持久化投影 = clipboardText:copy/cut 把选区内占位符展开;draft 持久化 mirror 写同一投影(chat store 里永远是普通文本,刷新 seed 语义 = 全选复制→重开→粘贴,chip 跨刷新降级为文本)。
-- 模型投影 = submit 时经 source `codec.serialize` 逐颗生成(归 submit attempt 的 signal 与陈旧守卫;owner 缺失/失败/取消则不发送,不降级为 `/name`)。
+效果面(shell 执行):`adjudicate`(调 InputTriggerController.adjudicate)、`begin-submit`(claim.submit 事务)、`default-sink`(普通消息,hub 编排)、`commit-draft`(前缀删除并保留后缀)、`notice`。
 
 ### 跨插件输入改写:三个 scoped bail 事件
 
@@ -56,21 +41,21 @@ occurrence 表与 chip 三投影:
 - `slash/input-insert-reference` `{reference, span}`——引用 chip 插入(InputTriggerController 派发)。
 - `slash/input-consume-token` `{guard: span | bare-token}`——业务成功后消费命令 token(下游命令面派发)。
 
-不事件化的调用(注册表登记 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。
+不事件化的调用(注册表登记 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。
 
 ### slash 流水线(ui-input-trigger:root `InputTriggerService` + 每会话 `InputTriggerController`)
 
 对「命令」零知识的触发/菜单/pick 流水线:
 
 - 服务只有 source 注册表(`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
-- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
+- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
 
 ### hub / facade:常驻外壳与严格会话输入体
 
 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。
 - 每个实体会话只有一个 `SessionInputShell`(facade),随会话作用域创建和拆除;无会话时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。
-- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——textarea DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
+- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在提示词成功受理后翻 false。
 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。
@@ -82,14 +67,14 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
-- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失
+- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本
 - 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
-- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
+- 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
 
 ### 每会话供数贡献与键盘私面
 
 - ui-conversation(hub 兼贡献者)经 `sessions.provide` 供 `'input'` hook(机器状态 + queue overlay)+ `inputActions` prop(`setDraft`/`submit`,稳定 void 回调)。
-- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。
+- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(`ComposerKeyboard`:shell 所有的 editor,arbitrate/space/paste/dismissPopup/caretSpan——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。
 
 ### slot 体系
 
@@ -97,7 +82,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 - `conversation.session.header`(single)——常驻 scrollport 上方严格会话的 breadcrumb、view tab 与 header action。
 - `conversation.session`(single)——常驻 scrollport 内严格会话的 view ring 与 draft mirror。header 和 body 共享同一个会话作用域 chat store;会话 id 切换时各自重建。
-- `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。
+- `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏编辑器表面的 DOM 存活。
 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自每会话 controller。
 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
 - `conversation.composer.dock`——composer 上沿统计带。
@@ -116,9 +101,9 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 | ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态 | claim 由 pick 路径显式建立——无表、无推导 |
 | bindTarget/bindDraft 对象直连 | 反向耦合 + root 单例跨会话误配;scoped bail 事件保依赖倒置且路由结构性正确 |
 | 统一 slash/input-apply 或全事件化 | 三个独立 payload 覆盖跨插件改写;异步链路保持基于注册表的显式调用 |
-| contenteditable / 富文本树 | 兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互约定 |
+| contenteditable / 富文本树 | 兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互约定——后被 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 反转:textarea 栈的样式与身份成本被实测后翻案 |
 | draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 |
-| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管 |
+| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管——两侧都随 textarea 一并退役;undo 现归 Lexical history |
 | InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 |
 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口 |
 | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
@@ -129,7 +114,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 ## 后果
 
-- 一个常驻 conversation 外壳承接无会话/blank/active:无会话 → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格会话 header 和 body outlet 开始承载内容。同一 blank 会话 → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费方一并删除。
+- 一个常驻 conversation 外壳承接无会话/blank/active:无会话 → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与编辑器表面;只有严格会话 header 和 body outlet 开始承载内容。同一 blank 会话 → engaging/active 也保持 InputBar 与编辑器表面。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费方一并删除。
 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 流水线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结约定。
 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
-- 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。
+- 已知欠账:chip 跨刷新保真未立项;subagent 引用的模型表示待业务立项。

+ 0 - 30
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md

@@ -1,30 +0,0 @@
-# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker
-
-Status: implemented
-
-English | [中文](2026-08-10-session-log-version-mechanism.zh.md)
-
-## Problem
-
-Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all.
-
-## Decision
-
-**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent.
-
-**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
-
-**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
-
-**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
-
-## Consequences
-
-What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
-
-## Alternatives considered
-
-- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises.
-- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
-- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
-- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.

+ 0 - 30
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md

@@ -1,30 +0,0 @@
-# Agent Note: Session log 版本机制:单调整数、升级器链、逐事件可忽略标记
-
-Status: implemented
-
-[English](2026-08-10-session-log-version-mechanism.md) | 中文
-
-## 问题
-
-Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。
-
-## 决定
-
-**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。
-
-**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
-
-**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
-
-**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
-
-## 影响
-
-v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
-
-## 曾考虑的替代方案
-
-- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。
-- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
-- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
-- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.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-18-experimental-agent-teams-packages.md
-2026-08-18-experimental-agent-teams-packages.md: cfaf7b23d03a1adecf8acc18a110717a62aa9ed1
-2026-08-18-experimental-agent-teams-packages.zh.md: 1d06addff6c8f347726408d3bb02bc72374492cb
+2026-08-18-experimental-agent-teams-packages.md: b769791d52472290f76eec7e518f24001db919ad
+2026-08-18-experimental-agent-teams-packages.zh.md: 25227770b5029b1baedef6bd9b7ad78b79586365

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md

@@ -12,12 +12,16 @@ An experimental directory without a current package previously imposed placement
 
 ## Decision
 
-`packages/experimental/agent-team` and `packages/experimental/tool-agent-team` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation.
+`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation.
 
 The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly.
 
 The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction.
 
+The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
+
+Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
+
 Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations.
 
 ## Alternatives considered
@@ -30,6 +34,6 @@ Experimental status changes publication and compatibility expectations only. The
 
 ## Consequences
 
-Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so CLI and Web experiments use explicit example or experimental compositions instead of the shipped base bundles.
+Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so the CLI experiment installs an explicit private profile layer instead of changing shipped bundles. The generic profile launcher accepts that layer without making its plugin dependencies part of the dsh release closure.
 
 The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package naming decision.

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md

@@ -12,12 +12,16 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess
 
 ## 决策
 
-`packages/experimental/agent-team` 与 `packages/experimental/tool-agent-team` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。
+`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。
 
 dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。
 
 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。
 
+私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。
+
+profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
+
 实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。
 
 ## 曾考虑的替代方案
@@ -30,6 +34,6 @@ dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/ex
 
 ## 后果
 
-Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 和 Web 实验使用显式示例或实验性组合,而不是已发布的基础组合包。
+Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 实验会安装显式的私有 profile 层,而不是修改已发布 bundle。通用 profile launcher 可以接受该层,而不会让它的 plugin 依赖进入 dsh 发布闭包。
 
 孵化期间的产品职责分组不够直接。promotion 会按照实验性包命名决策产生路径和 npm 名改动。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.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-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: d93aa64a53effa9d456b3eba2e1681b9478c448d
+2026-08-18-sqlite-physical-chunk-row-compression.md: 34aac2f183d386ffe22f86a6b62fe5e3105b3dfa
+2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 1845185d543f565b55ace6adac973dad5535ad7b

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

@@ -12,15 +12,15 @@ A physical row that represents several events affects append contiguity, crash r
 
 ## 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.
+`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-18 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.
+Schema 18 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 `is_packed=1`, while scalar rows set `is_packed=0`; the explicit discriminator prevents a scalar event whose type matches a storage tag from being decoded as packed. 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.
+SQLite owns chunk encoding and validation inside the schema-18 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.
+`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 18 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
 
@@ -32,11 +32,11 @@ Normal append never deletes or replaces an earlier event row. Fixed write-behind
 
 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.
+`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-18 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.
+A pristine database initializes at schema 18. 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
 
@@ -58,11 +58,11 @@ The repository regression guard writes 1,000 streamed deltas in 40-event durable
 
 **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.
+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. This comparison measured schema 17; schema 18 retains the chunk codec and bounds but changes the row discriminator, so the exact size and timing values remain schema-17 evidence until schema 18 is remeasured.
 
 **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.
+**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 18 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.
 

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

@@ -12,15 +12,15 @@ Status: implemented
 
 ## 决策
 
-`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
+`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 18 实现。它是唯一的 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` 成员。
+Schema 18 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行设置 `is_packed=1`,标量行设置 `is_packed=0`;显式判别值可防止类型与存储标签同名的标量事件被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
 
-SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
+SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、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,与不存在来源区分开来。
+`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 18 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
 
 ### 事务化追加打包
 
@@ -32,11 +32,11 @@ SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的
 
 完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
 
-`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
+`readFrom(id, fromSeq)` 只检查 schema 18 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
 
 ### Schema 所有权
 
-全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
+全新数据库初始化为 schema 18。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
 
 ### 物理写入回归
 
@@ -58,11 +58,11 @@ SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的
 
 **压缩每个 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 阈值是接受的平衡点,而不是严格支配所有指标的结论。
+最终冻结对比包含 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 阈值是接受的平衡点,而不是严格支配所有指标的结论。该对比测量 schema 17;schema 18 保留分片 codec 与上限,但改变行判别值,因此在重新测量 schema 18 前,精确的大小与时延值仍是 schema 17 证据。
 
 **把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
 
-**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
+**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 18 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
 
 **通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml → .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 81108ceaf23405c8f2def9aaef88505d635808a3
-2026-08-10-session-log-version-mechanism.zh.md: cbb127420e2695853fdc2ad0bb98a7a0bf230b5b
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
+2026-08-20-web-composer-lexical-editor.md: bc1f2f55c894ef547454d59977c70d696c071529
+2026-08-20-web-composer-lexical-editor.zh.md: 2ba7e495a7eb80877a096251f0982686016470d6

+ 61 - 0
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md

@@ -0,0 +1,61 @@
+# Agent Note: Web composer as a Lexical editor (chips as atomic nodes)
+
+Status: implemented
+
+English | [中文](2026-08-20-web-composer-lexical-editor.zh.md)
+
+> Scope: the composer's text surface (ui-conversation input/editor), the SubmitMachine that remains of InputMachine, and the projection contract feeding the untouched ui-input-trigger pipeline. Supersedes the draft/occurrence half of the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); its submit-plane, slot, and trigger-pipeline halves stay current.
+
+## Problem
+
+The textarea composer painted text in three coupled layers (hidden auto-grow mirror, decoration backdrop, transparent-text textarea) and held the draft twice (the textarea string and the machine's occurrence table). Both couplings produced structural bugs: reconciling occurrences against a string diff guessed the edit position, and a greedy scan sliding into a reference silently degraded it before the serialization guard could run (#2813); scan-derived decorations carried no identity, so typing ahead of one rebuilt its DOM every keystroke (#2793). The layer trick also taxed every chip style — nothing could change glyph advance, so no background, padding, radius, or label truncation.
+
+## Decision
+
+One Lexical editor per session shell replaces the three layers and the draft half of the machine.
+
+- **Ownership**: `SessionInputShell` creates the editor outside React (`createEditor` + `registerPlainText` + `registerHistory`) and keeps it for the session's lifetime; React binds a resident contenteditable to it (`ComposerContentEditable`, ~40 lines) and portals decorators (`DecoratorPortals`). `@lexical/react` is deliberately not used: its composer owns editor creation inside React, which conflicts with per-session shell ownership, and it drags an unused dependency tree.
+- **Chips are atomic `DecoratorNode`s** (`ReferenceChipNode`) carrying the owner's insert-time projections. NodeKey is the occurrence identity; `getTextContent()` answers the clipboard projection, so native copy/cut and the draft mirror need no expansion code.
+- **One tree, three projections**: the detect projection (chip = one U+FFFC) feeds `detectTrigger` and TokenSpan coordinates, restoring the opaque-reference invariant #2769 broke; the clipboard projection (chip = clipboardText) feeds `InputState.draft`, persistence, and submit-plane decisions; the model form is produced per chip at submit through the owner codec. `span-map.ts` is the single place numeric spans map back to Lexical points.
+- **The machine slims to the submit plane** (phase/claim/attempt); it never holds the draft — events carry the clipboard projection (`enter`, `submit-settled`), and the claimed integrity watch runs on `draft-changed`. Draft clearing became the `commit-draft` effect the shell executes in the editor (suffix retention included), followed by `CLEAR_HISTORY_COMMAND`.
+- **Contract stability**: `TokenSpan {start, end, draftRev}`, `ReferenceInsert`, `CommandClaim`, the four `slash/input-*` bail events, every trigger source, the controller, and MenuView are unchanged. `draftRev` is now the editor update counter.
+- **Claim tokens stay literal text** with a transform-styled leading leaf (backspacing the token remains the exit gesture); plain-text references ride `registerLexicalTextEntity` (`TextRefNode`); the ghost hint is a CSS `--dsh-composer-hint` variable rendered as generated content.
+
+## Retired with the rewrite
+
+The mirror/backdrop layers and their CSS coupling rules; the Safari soft-wrap repair (2026-08-13 note's workaround — the surface has no mirror to disagree with); mirror-Range caret measurement; the machine's undo ring and typing-merge clock (Lexical history, 1000ms merge delay preserved); manual boundary Backspace/Delete occurrence deletion (atomic nodes); manual copy/cut expansion; `EditRange`/`diffEdit`/`reconcile`. The paste-attempt plane (`paste-begin` components, `paste-upgrade`, `invalidate-paste`) and the `set-invalid` event had **no producers anywhere in the tree** and were deleted rather than ported; `Occurrence.invalid` stays on the node and the projection for the day a producer exists.
+
+## Deliberate behavior changes
+
+- Claimed command args now reach the source in clipboard form (references as canonical text, not display labels) — the parseable form.
+- `InputState.draft` is the clipboard projection (was display text). Cross-package readers consume phase/queue-level fields; the occurrence table had zero external readers.
+- Chip deletion follows the engine's native decorator gesture; jsdom lacks `Selection.modify`, so the keyboard path is asserted in the browser lane only.
+- Folder text-refs render the folder glyph as an icon prefix before the intact literal token (a currentcolor mask of the bubble's asset); the old backdrop overpainted the trigger character instead, which a Lexical text node cannot express.
+- The composer's accessible name is an explicit `aria-label` mirroring the placeholder (a div's `data-placeholder` does not name it the way a textarea's placeholder did) — caught by the reference-composer aria golden.
+- Caret-only commits publish nothing: the shell advances `draftRev` and re-publishes `InputState` only when the projection's content changes. Caret motion still feeds menu tracking, but it neither invalidates snapshot-built CAS spans (apply.ts builds spans from the published `draftRev`) nor re-renders subscribers. The first cut re-published on every commit; review caught the drift from the old machine's text-only revision.
+- A paste is its own undo boundary: the custom PASTE_COMMAND handler consumes the event before `@lexical/plain-text` could tag the update, so the shell attaches `PASTE_TAG` itself (via `$addUpdateTag` — the dispatch path always runs nested inside the command update). Without it, history merged a paste with typing inside the 1s window and one undo removed both.
+- The claim decoration outranks text-ref entities on the leading-token seat: a claimed command name that is also on the trigger lexicon stays a plain warn-styled TextNode, because Lexical transforms register per concrete node class and an entity capture would silently drop the claim color (probe-confirmed before the guard: the entity node won and the style was lost).
+
+## Alternatives considered
+
+- **Patch the textarea** (record beforeinput selections to narrow the diff, #2813's proposed fix): shrinks the guessing window but keeps two truths and the style tax; every future decoration pays it again.
+- **Hand-rolled contenteditable layer**: rejected by the dependencies-over-hand-rolling policy — IME, selection, and engine quirks are exactly what Lexical already owns.
+- **Deleting the machine entirely** (editor state as the only machine): the submit plane (attempt CAS, anti-backwash, abort) is text-independent and battle-tested; rewriting it buys risk, not simplicity.
+- **`@lexical/react`**: its composer creates the editor inside React, conflicting with per-session shell ownership, and pulls an unused dependency tree; the two bindings it would replace total ~80 lines.
+
+## Consequences
+
+- Bugs #2813 and #2793 are structurally unexpressible: no edit-position inference exists, and chip DOM identity rides NodeKey.
+- Chips are real DOM (icon, capsule, `max-width` truncation, invalid strike-through) and enter the accessibility tree; the old backdrop was `aria-hidden`.
+- The editor and its history survive session switches on the shell; unit tests drive the document headlessly, while true keyboard gestures (chip deletion, IME) belong to the browser lane.
+- The ui-conversation client bundle carries lexical (+~70KB gzip); no other package imports a Lexical value, so no module-table row exists.
+- The submit plane, trigger pipeline, and slash/input-* contracts are byte-compatible for every source plugin.
+
+## Traps
+
+- `editor.update` **defers** its fn when called from inside the same editor's update (command handlers land there synchronously); a nested discrete throws. `applyEdit` runs the `$`-body directly when `editor._updating` (legal in command handlers — the pattern Lexical itself uses for setEditable) and discretely at top level. A bail answer computed through a wrapped nested update reads stale state.
+- Lexical's chord/space detection reads `event.keyCode` (undo `z`=90, space=32); synthetic tests must set it.
+- A history restore (`UNDO_COMMAND`) commits on the next flush, not synchronously inside the dispatch.
+- The client bundle needs the `production`/`development` exports condition pinned (tsdown preset `inputOptions.resolve.conditionNames`): lexical's `node` condition file selects its flavor with a top-level await a CJS bundle cannot carry.
+- `registerHistory`'s merge delay reads `Date.now` at call time; fake-timer tests must install the mock before shell construction or advance past the window.
+- `isKeyboardSelectable()` must be **false** on the chip. With the default `true`, an arrow at the chip edge creates a NodeSelection whose DOM projection collapses to an element point, and the plain-text binding's arrow/delete/insert handlers all bail on non-Range selections — arrows, typing, and Backspace deadlock at the chip until a pointer click. False restores the placeholder semantics: arrows step across in one move, Backspace/Delete remove the chip whole (browser-lane e2e pins the gesture; only a real key event reproduces it — CDP raw keydowns carry no engine default).

+ 61 - 0
.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md

@@ -0,0 +1,61 @@
+# Agent Note:Web 输入框改为 Lexical 编辑器(chip 为原子节点)
+
+Status: implemented
+
+[English](2026-08-20-web-composer-lexical-editor.md) | 中文
+
+> 范围:输入框文本表面(ui-conversation input/editor)、InputMachine 瘦身后余下的 SubmitMachine,以及喂给原封不动的 ui-input-trigger 管线的投影契约。取代[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.zh.md) 中 draft/occurrence 的那一半;其提交面、slot 与 trigger 管线部分仍然有效。
+
+## 问题
+
+textarea 输入框用三个耦合层绘制文本(隐藏自增高 mirror、装饰 backdrop、文字透明的 textarea),且草稿存在两份(textarea 字符串与状态机的 occurrence 表)。两处耦合各自产生结构性 bug:occurrence 对齐依赖字符串 diff 猜测编辑位置,贪心扫描滑进引用内部会在序列化守卫运行前把它静默降级(#2813);扫描推导的装饰没有身份,在其前方打字每一击都重建它的 DOM(#2793)。层叠戏法还对每种 chip 样式征税——任何改变字形 advance 的样式都不可用,于是没有背景、内边距、圆角,也无法截断标签。
+
+## 决策
+
+每个会话壳持有一个 Lexical 编辑器,取代三层结构与状态机的草稿半边。
+
+- **所有权**:`SessionInputShell` 在 React 之外创建编辑器(`createEditor` + `registerPlainText` + `registerHistory`)并持有它到会话结束;React 侧把常驻 contenteditable 绑上去(`ComposerContentEditable`,约 40 行)并 portal 渲染 decorator(`DecoratorPortals`)。刻意不用 `@lexical/react`:其 composer 在 React 内部创建编辑器,与 per-session 壳所有权冲突,还会拖入用不到的依赖树。
+- **chip 是原子 `DecoratorNode`**(`ReferenceChipNode`),携带所有者插入时的投影。NodeKey 即 occurrence 身份;`getTextContent()` 回答剪贴板投影,因此原生复制/剪切与草稿镜像不再需要展开代码。
+- **一棵树,三个投影**:检测投影(chip = 1 个 U+FFFC)供 `detectTrigger` 与 TokenSpan 坐标使用,恢复了 #2769 打破的不透明引用不变量;剪贴板投影(chip = clipboardText)供 `InputState.draft`、持久化与提交面决策使用;模型形式在提交时逐 chip 经所有者 codec 产出。`span-map.ts` 是数字 span 映射回 Lexical point 的唯一场所。
+- **状态机瘦身为提交面**(phase/claim/attempt);它不再持有草稿——事件携带剪贴板投影(`enter`、`submit-settled`),claimed 完整性监视跑在 `draft-changed` 上。清空草稿变成 shell 在编辑器里执行的 `commit-draft` 效果(含后缀保留),随后 `CLEAR_HISTORY_COMMAND`。
+- **契约稳定**:`TokenSpan {start, end, draftRev}`、`ReferenceInsert`、`CommandClaim`、四个 `slash/input-*` bail 事件、所有 trigger source、controller 与 MenuView 一律未改。`draftRev` 现在是编辑器 update 计数。
+- **claim token 保持字面文本**,前缀叶子由 transform 上色(退格删 token 仍是退出手势);纯文本引用走 `registerLexicalTextEntity`(`TextRefNode`);ghost hint 是 CSS 变量 `--dsh-composer-hint` 生成内容。
+
+## 随重构退役
+
+mirror/backdrop 层及其 CSS 耦合规则;Safari 软换行修复(2026-08-13 note 的 workaround——表面已无可与之分歧的 mirror);mirror-Range 光标测量;状态机的 undo 环与打字合并时钟(Lexical history,保留 1000ms 合并窗口);手写的边界 Backspace/Delete 整段删除(原子节点原生);手写复制/剪切展开;`EditRange`/`diffEdit`/`reconcile`。粘贴尝试面(`paste-begin` components、`paste-upgrade`、`invalidate-paste`)与 `set-invalid` 事件**全仓没有任何生产者**,直接删除而非移植;`Occurrence.invalid` 保留在节点与投影上,待未来出现生产者。
+
+## 刻意的行为变化
+
+- 已认领命令的 args 现以剪贴板形式到达 source(引用为规范文本而非展示标签)——可解析的那种形式。
+- `InputState.draft` 是剪贴板投影(原为展示文本)。跨包读方只消费 phase/queue 级字段;occurrence 表的外部读方为零。
+- chip 删除遵循引擎的原生 decorator 手势;jsdom 缺 `Selection.modify`,键盘路径只在浏览器 lane 断言。
+- 文件夹纯文本引用在完整字面 token 前渲染文件夹图标前缀(气泡同款资产的 currentcolor mask);旧 backdrop 是覆盖绘制 trigger 字符,而 Lexical 文本节点无法表达这种覆盖。
+- 输入框的可访问名称改为显式 `aria-label` 镜像 placeholder(div 的 `data-placeholder` 不像 textarea 的 placeholder 那样参与命名)——由 reference-composer 的 aria golden 逮出。
+- 纯光标 commit 不发布任何东西:shell 只在投影内容变化时推进 `draftRev` 并重发布 `InputState`。光标移动仍然喂给菜单 tracking,但既不会使快照构造的 CAS span 失效(apply.ts 用已发布的 `draftRev` 构造 span),也不会触发订阅者重渲染。第一版每次 commit 都重发布;review 逮出了与旧机器「仅文本推进版本号」语义的漂移。
+- 粘贴是独立的 undo 边界:自定义 PASTE_COMMAND handler 在 `@lexical/plain-text` 有机会打 tag 之前就消费了事件,因此 shell 自己补上 `PASTE_TAG`(经 `$addUpdateTag`——dispatch 路径必然嵌套在命令 update 内部执行)。没有它,history 会把粘贴与 1 秒窗内的输入合并,一次 undo 同时撤销两者。
+- claim 装饰对行首 token 席位的优先级高于 text-ref 实体:被 claim 的命令名即使同时在触发 lexicon 上,也保持为普通的警告色 TextNode——因为 Lexical transform 按具体节点类注册,实体捕获会无声吃掉 claim 颜色(加守卫前经探针证实:实体节点胜出、样式丢失)。
+
+## 曾考虑的替代方案
+
+- **给 textarea 打补丁**(记录 beforeinput 时的 selection 收窄 diff,即 #2813 提议的修法):缩小猜测窗口但保留双事实源与样式税;未来每个装饰都要再交一次。
+- **自研 contenteditable 薄层**:被「依赖优先于手搓」政策否决——IME、selection 与引擎怪癖正是 Lexical 已经解决的本职。
+- **彻底删除状态机**(编辑器状态为唯一状态机):提交面(attempt CAS、防倒灌、abort)与文本表示无关且久经考验;重写只买来风险。
+- **`@lexical/react`**:其 composer 在 React 内创建编辑器,与 per-session 壳所有权冲突,还拉入用不到的依赖树;它能替代的两个绑定总共约 80 行。
+
+## 后果
+
+- #2813 与 #2793 在结构上不可表达:不存在编辑位置推断,chip DOM 身份随 NodeKey。
+- chip 是真实 DOM(图标、胶囊、`max-width` 截断、失效删除线)并进入可访问性树;旧 backdrop 是 `aria-hidden` 的。
+- 编辑器及其历史随壳跨会话切换存活;单元测试无头驱动文档,真实键盘手势(删 chip、IME)归浏览器 lane。
+- ui-conversation 的 client bundle 携带 lexical(gzip 约 +70KB);无其他包 import Lexical 值,故无模块表行。
+- 提交面、trigger 管线与 slash/input-* 契约对每个 source 插件字节兼容。
+
+## 坑
+
+- 在同一编辑器的 update 内再调 `editor.update` 会**推迟**其 fn(command handler 同步落到这里);嵌套 discrete 直接抛错。`applyEdit` 在 `editor._updating` 时直接执行 `$` 函数体(command handler 内合法——Lexical 自己对 setEditable 用同款分叉),顶层则 discrete。经包裹嵌套 update 计算的 bail 答案读到的是旧状态。
+- Lexical 的组合键/空格检测读 `event.keyCode`(undo `z`=90、空格=32);合成事件测试必须设置它。
+- 历史恢复(`UNDO_COMMAND`)在下一次 flush 才提交,不在 dispatch 内同步生效。
+- client bundle 需钉住 `production`/`development` exports 条件(tsdown preset 的 `inputOptions.resolve.conditionNames`):lexical 的 `node` 条件文件用顶层 await 选择口味,CJS bundle 载不动。
+- `registerHistory` 的合并延时在调用时捕获 `Date.now`;fake-timer 测试要么在 shell 构造前装好 mock,要么推进越过窗口。
+- chip 的 `isKeyboardSelectable()` 必须为 **false**。取默认值 `true` 时,方向键落在 chip 边缘会创建 NodeSelection,其 DOM 投影坍塌为 element point,而 plain-text binding 的方向键/删除/插入 handler 全都对非 Range selection 直接放弃——方向键、打字与退格在 chip 边死锁,直到鼠标点击才能解除。false 恢复占位符语义:方向键一步跨过,Backspace/Delete 整颗删除(浏览器 lane e2e 钉住该手势;只有真实按键事件能复现——CDP 裸 keydown 不携带引擎默认行为)。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.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-20-webworker-node-face.md
-2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81
-2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371
+2026-08-20-webworker-node-face.md: 05c3bfe7fa07b34189229f6454be597d4956226a
+2026-08-20-webworker-node-face.zh.md: 0a0f4badff192f02e962cfb38b64ef9d89fcd420

+ 2 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md

@@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 
 ## Decision
 
-**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly.
+**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. The shim includes `process.title`: packages such as `@xterm/headless` use that property's presence to select their Node path, while omitting it makes a dedicated Worker look like a browser Window and reaches DOM-only globals.
 
 **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning.
 
@@ -31,4 +31,5 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 - `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world.
 - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`.
 - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts.
+- `tests/node/process-shim.spec.ts` pins the Node detection field independently from the test runner's ambient Node process.
 - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites.

+ 2 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md

@@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 
 ## 决定
 
-**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
+**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。Shim 包含 `process.title`:`@xterm/headless` 等包通过该属性是否存在来选择 Node 路径;缺少它会让 dedicated Worker 被误判为浏览器 Window,进而访问仅适用于 DOM 的全局对象。
 
 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。
 
@@ -31,4 +31,5 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 - `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。
 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。
 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。
+- `tests/node/process-shim.spec.ts` 独立于测试运行器自带的 Node process,钉住 Node 环境识别字段。
 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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-20-webworker-pack-lowering-and-preview.md
-2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287
-2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1
+2026-08-20-webworker-pack-lowering-and-preview.md: 24dd5ba6e3eb633253321b219781b537f1faf429
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 5ee59e9a508767be928e2de804514b6357fe4ffd

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md

@@ -10,7 +10,7 @@ The browser worker can neither compile modules at load nor be served by the prod
 
 ## Decision
 
-**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
+**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; the same parse feeds reachability with statically named imports, re-exports, and dynamic imports, calls through `require`, and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import. Stored results, CommonJS-obtained `createRequire`, computed request names, and other bases stay runtime-only; targets reachable only through those forms require image entry seeds. Inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
 
 **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
 
@@ -37,7 +37,7 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental
 ## Consequences
 
 - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
-- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
+- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, the transform semantic suite pins `createRequire` request discovery, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
 - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not.
 - The served `<base href="/">` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
 - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md

@@ -10,7 +10,7 @@
 
 ## 决定
 
-**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
+**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;同一次解析会把具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用送入可达性遍历。保存下来的结果、经 CommonJS 获取的 `createRequire`、计算得到的请求名称与其他基准只在运行时解析;只能通过这些形式触达的目标需要镜像入口种子。worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
 
 **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
 
@@ -37,7 +37,7 @@
 ## 后果
 
 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
-- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
+- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,transform 语义套件钉住 `createRequire` 请求发现,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
 - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。
 - served 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.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-25-persistence-latency-and-page-size.md
+2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00
+2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea

+ 70 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md

@@ -0,0 +1,70 @@
+# Agent Note: Persistence compression latency and SQLite page size
+
+Status: implemented
+
+English | [中文](2026-08-25-persistence-latency-and-page-size.zh.md)
+
+## Problem
+
+The physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or session forks. The original 105-session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The earlier SQLite page-size experiment predated shared-dictionary row compression and showed negligible savings, so it did not establish the best page size for the current row distribution.
+
+The decision needs evidence from more varied sessions, including long event streams and payloads outside the original corpus. The expanded corpus contains 501 real sessions, 16,153,332 logical events, and 2,002,145,570 bytes of serialized event data.
+
+## Decision
+
+### Storage encoding stays physical and independently decodable
+
+JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
+
+SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
+
+### JSONL uses the standard Zstandard level
+
+The JSONL writer keeps one checksummed Zstandard frame per durable append batch but uses the compressor's standard level. Lossless `sourceEventSeqs` range encoding remains active. Frames stay independently decodable for suffix reads and torn-tail recovery; only the expensive level-19 search is removed.
+
+### New SQLite databases use 64 KiB pages
+
+The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation.
+
+The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
+
+### Expanded benchmark
+
+Each candidate was rebuilt five times from the same 501-session corpus with 512-event append batches. Their order rotates between rounds so every candidate occupies each run position once. Each build runs three complete and suffix-read sweeps. For each displayed metric, the highest and lowest build are discarded and the remaining three values are averaged. Complete and suffix read times cover one sweep over all sessions, and fork time covers all 501 sessions.
+
+| Backend | Stored size | Full write | Full read | Suffix read | Fork |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
+| JSONL with provenance ranges | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
+| JSONL with provenance ranges and level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
+| SQLite `master` (schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
+| SQLite with all physical optimizations and 64 KiB pages | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
+
+Relative to standard-level frames with provenance ranges, level 19 saves another 12.1% of the JSONL bytes but increases full-write time by 67.0% and fork time by 129.8%. Its complete and suffix reads change by -0.4% and -0.5%. The extra search therefore benefits retained size without improving the latency-sensitive operations enough to offset its repeated encoding cost.
+
+An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages use 256.97 MB and 64 KiB pages use 233.18 MB (-9.26%). The `events` table's unused page bytes fall from 30.25 MB to 6.95 MB, while the index changes from 5.92 MB to 6.03 MB. In the paired run, full write, full read, and suffix read change by -0.5%, -0.4%, and -3.8%; fork changes by -14.8%. The space gain therefore comes from better large-row page utilization rather than a smaller index or omitted data, without a measured latency regression.
+
+## Alternatives considered
+
+**Keep JSONL level 19.** Rejected. On the expanded corpus it saves another 12.1% relative to default-level frames but increases full-write time by 67.0% and fork time by 129.8%, while complete and suffix reads differ by less than 1%. Default-level frames plus provenance ranges retain a 14.1% size reduction relative to master without a material latency regression.
+
+**Compress one whole JSONL log as a single frame.** Rejected. It improves cross-batch compression but makes suffix reads decompress from the start and removes batch-local torn-tail recovery.
+
+**Keep 4 KiB SQLite pages.** Rejected for pristine databases. The current compressed-row distribution retains 9.26% more bytes because large compressed records leave more unusable space across 4 KiB B-tree pages. Existing databases keep their page size to avoid a historical rewrite.
+
+**Remove ROWID from `events`.** Rejected. The composite primary key becomes the table B-tree key and repeats through internal pages; the 105-session comparison produced a larger database than ordinary ROWID tables.
+
+**Deduplicate event content.** Rejected. Message restatements and tool arguments can be reconstructed only under assumptions that compaction, retries, and pruning may invalidate. Physical compression preserves every event without adding reconstruction semantics.
+
+**Use per-session SQLite files or DuckDB.** Rejected for the hot store. Per-session files lose cross-session queries, while DuckDB's OLAP write model fits cold batch analysis rather than durable append batches and low-latency suffix reads.
+
+## Consequences
+
+JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
+
+New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it.
+
+## Related
+
+- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.md) — owns the packed row model; its earlier page-size conclusion applies to the pre-dictionary layout.
+- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.md) — owns the checksummed frame-per-batch container and the standard compressor-level policy restored here.

+ 70 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md

@@ -0,0 +1,70 @@
+# Agent Note: 持久化压缩延迟与 SQLite page size
+
+Status: implemented
+
+[English](2026-08-25-persistence-latency-and-page-size.md) | 中文
+
+## 问题
+
+物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或会话 fork。原有的 105 会话语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。此前的 SQLite page-size 实验早于共享字典行压缩,所得空间收益可以忽略,因此无法确定当前行分布的最佳 page size。
+
+该决策需要来自更多样会话的证据,包括长事件流与原语料之外的 payload。扩展后的语料包含 501 个真实会话、16,153,332 个逻辑事件与 2,002,145,570 字节序列化事件数据。
+
+## 决策
+
+### 存储编码保持为物理层行为并可独立解码
+
+JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
+
+SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
+
+### JSONL 使用 Zstandard 标准级别
+
+JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Zstandard frame,但使用压缩器的标准级别。无损 `sourceEventSeqs` 区间编码继续生效。各 frame 仍可独立解码,以支持后缀读取与撕裂尾部恢复;只移除昂贵的 level-19 搜索。
+
+### 新建 SQLite 数据库使用 64 KiB page
+
+SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。
+
+Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
+
+### 扩展基准
+
+每个候选方案都从同一份 501 会话语料独立重建五次,每个 append 批次包含 512 个事件。各轮轮换执行顺序,使每个候选方案在每个运行位置各出现一次。每次重建执行三轮完整读取与后缀读取。下表中的每项指标都去掉最高与最低的一次重建,再平均其余三次。完整读取与后缀读取耗时覆盖对全部会话的一轮扫描,fork 耗时覆盖全部 501 个会话。
+
+| 后端 | 存储大小 | 完整写入 | 完整读取 | 后缀读取 | Fork |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
+| JSONL + 来源区间 | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
+| JSONL + 来源区间 + level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
+| SQLite `master`(schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
+| SQLite + 全部物理优化 + 64 KiB page | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
+
+相对使用来源区间的标准级别 frame,level 19 可再减少 12.1% 的 JSONL 字节,但会让完整写入增加 67.0%、fork 增加 129.8%;完整读取与后缀读取分别变化 -0.4% 与 -0.5%。因此,更深入的搜索只改善保留体积,无法通过延迟敏感操作的收益抵消反复付出的编码成本。
+
+其余条件相同的 SQLite 重建可单独观察 page-size 影响:4 KiB page 使用 256.97 MB,64 KiB page 使用 233.18 MB(-9.26%)。`events` 表的 page 内未使用字节从 30.25 MB 降至 6.95 MB,索引则从 5.92 MB 变为 6.03 MB。在该成对运行中,完整写入、完整读取与后缀读取分别变化 -0.5%、-0.4% 与 -3.8%,fork 变化 -14.8%。因此,空间收益来自更高的大记录 page 利用率,而不是索引缩小或数据省略,并且没有测得延迟退化。
+
+## 考虑过的替代方案
+
+**保留 JSONL level 19。** 不予采用。在扩展语料上,它相对默认级别 frame 可再减少 12.1%,却让完整写入增加 67.0%、fork 增加 129.8%,而完整读取与后缀读取的差异都不足 1%。默认级别 frame 配合来源区间后,相对 master 仍能缩小 14.1%,且没有实质性延迟退化。
+
+**把整份 JSONL 日志压成单个 frame。** 不予采用。该方案可改善跨批次压缩,但后缀读取必须从头解压,也会失去按批次恢复撕裂尾部的能力。
+
+**新建 SQLite 数据库继续使用 4 KiB page。** 不予采用。当前压缩行分布会在 4 KiB B-tree page 之间留下更多不可用空间,使保留字节增加 9.26%。已有数据库保留其 page size,避免改写历史数据。
+
+**从 `events` 移除 ROWID。** 不予采用。复合主键会成为表 B-tree 键并在内部 page 中重复;105 会话对比所得数据库大于使用普通 ROWID 的表。
+
+**对事件内容去重。** 不予采用。消息复述与工具参数只能在依赖重建假设时删除,而 compaction、重试和修剪可能让这些假设失效。物理压缩保留每个事件,不增加重建语义。
+
+**使用逐会话 SQLite 文件或 DuckDB。** 不用于热存储。逐会话文件会失去跨会话查询,DuckDB 的 OLAP 写入模型则更适合冷批量分析,而不是持久 append 批次与低延迟后缀读取。
+
+## 后果
+
+JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
+
+新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。
+
+## 相关资料
+
+- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) — 定义打包行模型;其此前的 page-size 结论适用于共享字典之前的布局。
+- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.zh.md) — 定义带 checksum 的按批次 frame 容器,以及本笔记恢复的标准压缩级别策略。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.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-26-models-page-extension-slots.md
+2026-08-26-models-page-extension-slots.md: 9983c669b7838de00d49a634a54e5d115ef4945a
+2026-08-26-models-page-extension-slots.zh.md: 639654ea4b912d43a95f0a9295969523f4e1c5f8

+ 33 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md

@@ -0,0 +1,33 @@
+# Agent Note: Models-page extension slots
+
+Status: implemented
+
+English | [中文](2026-08-26-models-page-extension-slots.zh.md)
+
+## Problem
+
+Provider sign-in for the pi-ai catalog (GitHub Copilot, OpenAI accounts) is moving out of the product into an optional out-of-tree plugin for provider terms-of-service reasons. The plugin needs its sign-in button and attempt UI inside the Models page's provider cards — the surface where a user meets a provider — but `ui-settings-models` rendered its cards from closed code: the only integration path was editing this package, which an external plugin cannot do, and the page's one open seam (`settings.section`) can only add a whole separate page.
+
+## Decision
+
+`ui-settings-models` declares two SlotMap seats in `src/client/slot-contract.ts`, claims them as `children` of its `settings.section` registration, and re-exports their types from `./client` so an out-of-tree plugin can merge them with a type-only import.
+
+`settings.models.provider-card` is `keyed` with `entryKey = ConfigurableProviderView.settingsNs`: one registration under an adapter family's settings namespace receives every card of that family — shipped catalog routes, adopted directory rows, and hand-declared routes alike — while the section never interprets the key. The key domain stays the open string space (no `keyProps` table) because hand-declared route ids are user-chosen at runtime. The seat dispatches on every card that shows a directory row: a saved row's card, its first-run setup posture, and the add-provider draft (its dormant row, `configured: false` in practice), which is where sign-in matters most — the user has just met the provider and holds no key. The hand-declared draft card has no directory row before saving and dispatches nothing. Owner props carry the row's `ConfigurableProviderView`, its `configured` join, and its confirmed api-key credential state (`keyConfigured`, which the first consumer uses to withhold sign-in beside a stored key); nothing more has a current consumer.
+
+`settings.models.footer` is a `list` seat after the rows and the add controls, for section-level extension content such as orphaned-record management.
+
+Without registrants both seats render nothing, so the shipped page is pixel-identical to before.
+
+## Alternatives considered
+
+**A `list` seat with self-filtering registrants instead of keying.** Every registrant would render (and return null) on every card, and two plugins could silently interleave UI inside one family's cards. Keying by namespace gives one accountable extension owner per adapter family and zero wasted dispatches, and reuses the exact pairing rationale of `settings.plugin.item`.
+
+**Keying by provider route id.** Route ids are dynamic — hand-declared routes are named by users at runtime — so a plugin could not register ahead of the rows it wants and would have to churn registrations as the directory changes.
+
+**A `chain` seat replacing the whole card body.** No current consumer needs to replace the editor; the sign-in surface is additive. A takeover contract would also make the section's layout a compatibility surface. A chain can still be added later without disturbing these seats.
+
+**Keeping the sign-in UI wired inside `ui-settings-models` (the pre-plugin design).** Ships the terms-of-service-sensitive surface in the product, which is the outcome this extension point exists to avoid.
+
+## Consequences
+
+An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. Per adapter family the keyed cell renders one owner at a time: a second registration under the same namespace at the same priority is refused by the registry, while a different priority deliberately shadows it (the lowest-priority entry renders) — the slot kit's standard override channel, never a silent merge.

+ 33 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Models 页扩展插槽
+
+Status: implemented
+
+[English](2026-08-26-models-page-extension-slots.md) | 中文
+
+## Problem
+
+出于提供方服务条款的考虑,pi-ai catalog 的提供方登录(GitHub Copilot、OpenAI 账号)正从产品中移出,改由一个可选的仓库外插件承担。该插件需要把登录按钮与登录过程 UI 放进 Models 页的提供方卡片——用户与提供方相遇的界面——但 `ui-settings-models` 的卡片由封闭代码渲染:唯一的集成途径是修改本包,外部插件做不到;页面仅有的开放缝隙(`settings.section`)只能新增一整个独立页面。
+
+## Decision
+
+`ui-settings-models` 在 `src/client/slot-contract.ts` 声明两个 SlotMap 席位,在其 `settings.section` 注册中以 `children` 认领它们,并从 `./client` 再导出其类型,使仓库外插件通过 type-only import 即可获得类型合并。
+
+`settings.models.provider-card` 为 `keyed`,`entryKey = ConfigurableProviderView.settingsNs`:以某适配器家族的 settings namespace 注册一次,即可收到该家族的全部卡片——内置 catalog 路由、从目录采纳的行、手工声明的路由一视同仁——而分区从不解释这个 key。键域保持开放字符串空间(不设 `keyProps` 表),因为手工声明的路由 id 由用户在运行时命名。该席位在每张展示目录行的卡片上分发:已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡(其休眠行,实际为 `configured: false`)——草稿卡正是登录价值最大的时刻:用户刚遇到该提供方、手中还没有密钥。手工声明的草稿卡在保存前没有目录行,不分发。Owner props 携带该行的 `ConfigurableProviderView`、其 `configured` 合并结果与已确认的 api-key 凭据状态(`keyConfigured`,首个消费者用它在已存密钥旁抑制登录入口);更多字段没有现役消费者。
+
+`settings.models.footer` 为 `list` 席位,位于行列表与新增控件之后,承载孤儿记录管理这类分区级扩展内容。
+
+没有注册方时两个席位均不渲染,产品页面与之前逐像素一致。
+
+## Alternatives considered
+
+**用 `list` 席位、由注册方自行筛选,替代按键分发。**每个注册方都会在每张卡片上渲染(再返回 null),且两个插件可能在同一家族的卡片里静默交错 UI。按 namespace 分发让每个适配器家族有唯一可问责的扩展所有者、零浪费分发,并完全复用 `settings.plugin.item` 的配对理由。
+
+**按提供方路由 id 分发。**路由 id 是动态的——手工声明的路由由用户在运行时命名——插件无法先于目标行注册,还得随目录变化反复重注册。
+
+**用 `chain` 席位整体接管卡片。**没有现役消费者需要替换编辑器;登录界面是加法。接管契约还会让分区布局成为兼容面。将来仍可在不动这两个席位的前提下追加 chain。
+
+**把登录 UI 继续织在 `ui-settings-models` 里(插件化之前的设计)。**会把服务条款敏感的界面随产品发布,而这正是本扩展点要避免的结果。
+
+## Consequences
+
+仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族的 keyed 单元格同一时刻只渲染一个所有者:同一 namespace 下同 priority 的第二次注册会被注册表拒绝,不同 priority 则是刻意的遮蔽(最低 priority 的条目渲染)——这是 slot 套件的标准覆盖通道,绝非静默合并。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.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-26-preset-health-resolves-rows.md
+2026-08-26-preset-health-resolves-rows.md: 909423e373bc2368b5fb1e81b1172275920282aa
+2026-08-26-preset-health-resolves-rows.zh.md: d2cffae66f7bd7fb78e068bd4242d009b95d3c5c

+ 75 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md

@@ -0,0 +1,75 @@
+# Agent Note: Preset health resolves the rows it can prove will start
+
+Status: implemented
+
+English | [中文](2026-08-26-preset-health-resolves-rows.zh.md)
+
+## Problem
+
+A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config.
+
+This note partly supersedes [broken presets are roster rows](../bug-fix/2026-08-09-broken-preset-roster-rows.md), whose rejected "validating deep" alternative is what shipped here, and it moved the reason off the card face; it also relaxes the shipped-roster assertion [plugin-owned shipped preset root](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md) records. Both are updated in place.
+
+`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose".
+
+The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `<dshHome>/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded.
+
+The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row.
+
+## Decision
+
+**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `<package>/package.json`. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way.
+
+The disk lookup, not `import.meta.resolve`, for two reasons. It is the cheap one: a registered ESM loader hook turns every resolver call into a synchronous round-trip to the hooks thread, measured under the `tsx` hook the source launch installs at 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node — 238ms of resolver time per roster read, where the walk answers the same 135 rows in 0.7ms. It is also the only one that can be asked about the harness at all: `import.meta.resolve`'s `parentURL` argument takes effect only under `--experimental-import-meta-resolve`, which no launch passes, so it resolves from the calling module and would answer about this package rather than about the deployment. The resolver that does honour an explicit parent is the Loader's internal one, whose `resolveSync` signature differs between Node 22 and 24. A Node builtin short-circuits ahead of the walk.
+
+What the walk gives up: a package resolvable only through a loader hook — an import map, or a tree with no `node_modules` — is reported broken. No supported install produces one, because `dsh plugin install` puts every plugin beside the roster.
+
+**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load.
+
+**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner.
+
+**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end.
+
+**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them.
+
+**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is revealed by hovering the badge or focusing the card, and a visually hidden `role="alert"` node announces it. A broken card says so through `aria-disabled` rather than `disabled` and refuses the pick in its own handler: `disabled` would take it out of the tab order, and with the reason off the face that would leave anyone without a pointer unable to reach it at all.
+
+**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand.
+
+The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice.
+
+## Alternatives considered
+
+**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict.
+
+**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost.
+
+**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design.
+
+**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one.
+
+**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp.
+
+**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine.
+
+**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part.
+
+**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops.
+
+**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way.
+
+**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line.
+
+**Make the badge itself the focusable control.** Rejected: the badge sits inside the card's own `<button>`, so a focusable trigger there means restructuring the card head. Keeping the card focusable through `aria-disabled` reveals the same tooltip from the same key press and changes no layout. Leaving the reason reachable by pointer alone was rejected too — it was visible without any interaction before this change, so hiding it behind hover is a regression for anyone reading by keyboard rather than a path that never existed.
+
+## Consequences
+
+A preset naming a package that a rename or an uninstall took away is marked on the roster, refused before a mount is spent, and dropped from the pickers — the same treatment a ghost directory already got. The reason names each row at fault, and a failure that survives to mount names every row inside a group rather than the group alone.
+
+Health answers from what is installed, not from what would import: a package present but exporting a file that is missing still reports healthy and still fails at mount. That is the safe direction — under-reporting returns the previous behavior, while a false broken makes a usable preset unselectable — and it keeps the answer out of the build state of any one package. A source checkout is still not an installed host, though, because a shipped row names a package the deployment installs beside the roster: `shipped-root.spec.ts` asserts the shipped presets carry no reason other than unresolved rows rather than no reason at all. The mount fixtures name a module that loads and then refuses, since a fixture naming a file that does not exist can no longer reach the mount.
+
+A mount failure is now legible where it happens, which matters most for the failures health can never catch: a row that resolves and then refuses is reported healthy on the roster forever, so the banner is not a convenience over the card — it is the only account of that failure anywhere.
+
+Measured in the web app on a roster of eleven presets, `agentPreset.list` answers in 14ms cold and 6-8ms after, and the three concurrent reads the client opens with settle in 9ms of wall clock. The same reads took 2.45 seconds each while every row went through the resolver.
+
+`@deepseek-ai/cordis-plugin-group` is a devDependency of `dsh-agent-presets`: the mount fixtures now compose through `cordis:group` the way real presets do, and a preset outside the workspace cannot resolve that package by name, so the app registers it as a builtin and the fixture harness does the same.

+ 75 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md

@@ -0,0 +1,75 @@
+# Agent Note: Preset health resolves the rows it can prove will start
+
+Status: implemented
+
+[English](2026-08-26-preset-health-resolves-rows.md) | 中文
+
+## Problem
+
+名单列为健康的 preset,仍可能根本无法组装。发现过程的健康检查只证明组装能以加载器方言解析、由具名行组成,并刻意止步于此——它不解析任何插件名,也不应用任何配置。
+
+本 note 部分取代了[损坏的 preset 是名单行](../bug-fix/2026-08-09-broken-preset-roster-rows.zh.md):那份 note 在 Alternatives 中否决的「深度校验」正是这里落地的做法,而且原因也已移出卡片正面;它同时放宽了[插件自带内置 preset 根](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md)记录的随附名单断言。两份都已就地更新。
+
+但 `broken` 是承重的,不是卡片上的装饰。`presetOptions` 会把损坏的行从会话选择器里滤掉,好让选择的人不必等到会话启动失败才发现;`resolveMountable` 会在花费一次挂载之前拒绝它。因此下游一切都把「不是 broken」读作「能组装」。
+
+这个缺口在[仓库命名契约](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)按预发布立场重命名包时暴露出来。仓库内的引用随之更新;写在 `<dshHome>/.agent-presets` 下的 preset 没有,于是引用 `@deepseek-ai/dsh-workspace-context` 的那一个保住了健康的卡片、保住了在选择器里的位置,直到有人切换过去才失败。引用了被后续版本改名或卸载的包,正是手写 preset 真正的腐化方式,而它恰好是这项检查排除掉的那一类。
+
+而它真正产出的失败,说得比它知道的还少。加载器的逐行包装构造一个普通 `Error`,其 message 以 `cause.message` 结尾,cause 只留在 `error.cause` 上。于是一个有两行失败的 group,抵达时是一行被包装的行,message 为 `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`,两条真正的原因只能经由 `cause.errors` 取得。挂载诊断只展平 `AggregateError.errors`,从不跟随 `cause`,因此它停在那一行,一行都没点名。
+
+## Decision
+
+**发现过程解析每一行它能证明会启动的行,且不 import 任何东西。** 解析这一趟跑在 `packages/preset/agent-presets/src/discovery.ts` 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 `node_modules`、停在 `<包>/package.json` 的做法。preset 相对路径与绝对路径改用 stat,因为对这两类 `import.meta.resolve` 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
+
+用磁盘查找而不是 `import.meta.resolve`,有两个理由。它便宜:只要注册了 ESM loader hook,每一次解析器调用就变成一次到 hooks 线程的同步往返,在源码启动所用的 `tsx` hook 下实测命中 2ms、未命中 5ms,而裸 Node 分别是 0.055ms 与 0.032ms——每次名单读取要背上 238ms 的解析器时间,而同样这 135 行磁盘走法只要 0.7ms。它也是唯一问得到「宿主」的:`import.meta.resolve` 的 `parentURL` 参数只在 `--experimental-import-meta-resolve` 下生效,而没有任何启动方式传它,因此它是相对调用方模块解析的,回答的是关于本包而不是关于部署的问题。真正认显式 parent 的是 Loader 的内部解析器,而它的 `resolveSync` 在 Node 22 与 24 上签名不同。Node 内建模块在磁盘查找之前直接短路。
+
+磁盘走法放弃了什么:只有经由 loader hook 才能解析的包——import map,或根本没有 `node_modules` 的目录树——会被报为损坏。任何受支持的安装都不会产出这种情况,因为 `dsh plugin install` 会把每个插件装在名单旁边。
+
+**只有一个分类器决定一行在哪里解析。** `src/specifier.ts` 拥有这个划分——`cordis:` 内建、preset 相对、绝对文件、包名——挂载的 import 覆写与发现过程的检查都读它。若发现过程按一个基准解析、而挂载按另一个基准 import,那一行会被报告为健康,然后加载失败。
+
+**可能永远不会启动的行被跳过。** `disabled` 是[加载器唯一会插值](2026-08-11-loader-entry-disabled-interpolation.zh.md)的条目字段:`!!js` 表达式在挂载时对加载器上下文求值,而发现过程无法仅凭文件做到。凡该字段不是缺失、null 或 `false` 的行都不做检查,被禁用的 group 连同其子行一起跳过。每个随附 preset 都用这种方式为 shell 行设门,所以这是常见形状,不是边角。
+
+**harness base 是必填参数。** `discoverPresets(roots, harnessBase)` 与 `scanRoot(root, harnessBase)` 都接收它;`AgentPresets` 在构造函数里读一次 `ctx.baseUrl`,缺失就抛。基准正是让这个问题可回答的前提——同一个包名从 preset 自己的目录解析会失败、从已安装的 harness 解析会成功——所以做成可选就等于悄悄恢复这项检查要终结的那个状态。
+
+**挂载诊断跟随携带信息多于自身 message 的 cause。** `mountDetail` 从 `AggregateError.errors` 取分支,或在 cause 是 `AggregateError` 时从 `error.cause.errors` 取;普通的 cause 链已被展平进 message,不再跟随,否则每一行都会打印两遍。嵌套分支在拥有它的那一行下缩进。
+
+**客户端把原因放到徽标上。** 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因在悬停徽标或聚焦卡片时展开,另有一个视觉隐藏的 `role="alert"` 节点负责朗读。损坏的卡片用 `aria-disabled` 而非 `disabled` 表达这件事,并在自己的处理函数里拒绝这次选择:`disabled` 会把它移出 tab 序列,而原因已不在正面,那等于让不用指针的人完全够不到它。
+
+**被拒绝的切换要在被拒绝的地方说明原因。** chip 的标签会弹回会话仍在运行的那个 preset,因此不说话的话,这次选择看起来就像根本没发生。它经由共享的 `Toast` 在 composer 列上方自报,与旁边的模型选择器报告被拒绝的选择方式一致。只有人刚做出的选择会被自报——应用器在会话成为当前会话时也会运行,为那种情况弹横幅等于报告一个没人问过的拒绝。横幅停留八秒而非 primitive 默认的三秒,因为它承载的原因要点名包与行;`Toast` 为此获得了 `holdMs`,顺带也消除了「停留常量必须由人手与样式表保持同步」这一隐患。
+
+线上本来就把这需要的两段文本分开了:`message` 把原因裹进名单自己的「preset X failed to mount」框架,而 `details.reason` 只保留原因本身。自己会点名 preset 的表面取后者,否则会把 preset 说两遍。
+
+## Alternatives considered
+
+**在选中 preset 时检查,而不是在列出名单时。** 否决。选择器在任何人选中之前就按 `broken` 过滤,因此只在选中时检查的 preset 仍会被摆出来,报出的失败仍在点击之后到达——原本的抱怨只是换了个位置。名单行才是每个消费者已经在读判定的地方。
+
+**让 base 可选,没有它就跳过检查。** 否决。它的失败模式正是要修的这个 bug,而且不带任何信号:无法组装的 preset 顶着健康卡片。`ctx.baseUrl` 在任何作用域上下文派生之前就设在根上,因此这个抛出是对「不会发生的事」的断言,而不是一条有运行时代价的分支。
+
+**import 每一行而不是解析它。** 否决。import 会在每次读取名单时执行模块顶层代码,这是选择器不该有的副作用,而且那是挂载的职责——在 apply 时抛错、或永远等待某个服务的插件仍按设计在第一个会话处失败。
+
+**每一行都交给 `import.meta.resolve` 解析。** 先这样发出去,实测后回退:它是对的,但每次名单读取要 445ms,而客户端并发的三次读取把它放大成每次 2.45 秒——设置分区肉眼可见地卡住。解析器确实是「什么能 import」的权威,但为明摆着装好的行去问它,等于为每一行付一次 hooks 线程往返。
+
+**把整个 `compositionProblem` 缓存在已有的 `CompositionStamp` 上。** 作为省开销的手段被否决:它只能让重复读取免费,每个被编辑过的组装的第一次读取仍是全价;而且它把解析结果挂在组装文件上,可安装状态变化时组装文件并不会变。改用磁盘查找直接消掉了开销,于是没有什么还需要这个 stamp。
+
+**把切换失败送到名单卡片上,而不是弹横幅。** 否决:卡片恰恰是那些能走到挂载的失败看不见的地方。所有行都能解析的组装会被报告为健康,于是「去设置页看原因」指向的是一张写着「这个 preset 没问题」的卡片。
+
+**只报第一个无法解析的行,与形状检查保持一致。** 否决。解析失败会连锁,所以在那里只点名一个是诚实的;无法解析的名字是彼此独立、一次即可全部知晓的事实,而一次重载只修一个才是可以避免的部分。
+
+**在 `mountDetail` 里无条件跟随 `error.cause`。** 否决。加载器的包装已经把 `cause.message` 追加进它构造的 message,因此普通链会把每一行渲染两遍。`AggregateError` 类型的 cause 是唯一被 message 丢掉细节的形状。
+
+**继续把原因渲染在卡片正面。** 否决。原因里是包说明符和路径,把它们摆在 preset 描述的位置,等于用选择者需要的东西换取修复者需要的东西——而修复者需要的那份,无论如何都只隔一次悬停。
+
+**复用图标行的 `data-tip` 伪元素来做提示条。** 实测后否决:生成内容会并入元素的可访问文本,因此卡片的 aria 快照多出一份 alert 已经携带的原因的逐字副本。改用真实的 `aria-hidden` 元素后,可访问副本恰好只有一份——而且既有那条提示条是为图标标签准备的单行 `nowrap`,这一条要逐行列出包说明符。
+
+**把徽标本身做成可聚焦控件。** 否决:徽标位于卡片自身的 `<button>` 内部,在那里放可聚焦触发器意味着改造卡片头部结构。用 `aria-disabled` 让卡片保持可聚焦,同一次按键就能展开同一条提示条,且不改动任何布局。「原因只用指针可达」同样被否决——这次改动之前它无需任何交互就可见,因此把它藏到悬停之后,对用键盘阅读的人是退化,而不是一条本就不存在的路径。
+
+## Consequences
+
+引用了被改名或卸载掉的包的 preset,会在名单上被标出、在花费挂载之前被拒绝、并从选择器里剔除——与幽灵目录早已得到的待遇一致。原因会点名每一个出问题的行;而活到挂载阶段的失败,会点名 group 内的每一行,而不只是 group 本身。
+
+健康依据的是「装没装」,而不是「能不能 import」:包在、但导出指向的文件不在,仍会报告健康并仍在挂载时失败。这是安全的方向——漏报只是退回先前的行为,而误报会让一个可用的 preset 变得不可选——而且它让答案不依赖于任何单个包的构建状态。不过源码检出仍不是已安装宿主,因为随附行引用的是部署与名单装在一起的包:`shipped-root.spec.ts` 断言随附 preset 除未解析行之外不携带其他原因,而不是不携带任何原因。挂载夹具改为引用一个能加载、随后拒绝的模块,因为引用不存在文件的夹具已经到不了挂载。
+
+挂载失败现在在它发生的地方就可读,这对健康永远抓不到的那类失败最要紧:能解析、随后拒绝的行在名单上永远显示健康,因此这条横幅不是卡片之外的一份便利——它是那类失败在任何地方唯一的交代。
+
+在 web 应用中对十一个 preset 的名单实测:`agentPreset.list` 冷启动 14ms、之后 6–8ms,客户端开场并发的三次读取合计 9ms wall。在每一行都过解析器的版本里,同样这三次读取各要 2.45 秒。
+
+`@deepseek-ai/cordis-plugin-group` 成为 `dsh-agent-presets` 的 devDependency:挂载夹具现在像真实 preset 那样经由 `cordis:group` 组装,而工作区之外的 preset 无法按名解析该包,所以应用把它注册为内建,夹具 harness 也照做。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.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-07-30-approval-panel-command-cap.md
-2026-07-30-approval-panel-command-cap.md: 1d74d790299522a0f1e6b23f342cd10b35192d27
-2026-07-30-approval-panel-command-cap.zh.md: 35263df4df4460ccfad4a0c11704997f4b2e27f2
+2026-07-30-approval-panel-command-cap.md: 0bfdc2d00fa4a0f9fa9ee71400d6f3cc2350e617
+2026-07-30-approval-panel-command-cap.zh.md: a086888b12c0164b931019f4d73102bd11bccf4b

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md

@@ -34,7 +34,7 @@ The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as eve
 
 - A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport.
 - Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves.
-- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`, on the box that scrolls its draft ([one scrollport for both text layers](2026-07-31-composer-text-layers-share-one-scrollport.md) moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
+- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`, on the box that scrolls its draft ([one scrollport for both text layers](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md) moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
 - The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing).
 
 ## Verification

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md

@@ -34,7 +34,7 @@ Status: implemented
 
 - 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。
 - 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的 transcript(文本记录)不会有数百像素的重排。
-- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
+- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
 - 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把「alpha 重复 400 次」写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。
 
 ## 验证

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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-04-composer-tab-gutter-reservation.md
-2026-08-04-composer-tab-gutter-reservation.md: 37a07b2b98bc676ff9c11fe90bc8e346f6189690
-2026-08-04-composer-tab-gutter-reservation.zh.md: bd6a7899515505017549960c2a5769e9ab076ca5
+2026-08-04-composer-tab-gutter-reservation.md: ed8d973c289fcb92b33cc468551aabe5ebb9eee7
+2026-08-04-composer-tab-gutter-reservation.zh.md: 282151ecbfe6ab046b785c8a8bfcd88781607449

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md

@@ -18,7 +18,7 @@ So for as long as the transcript overflowed — the ordinary state of any sessio
 
 `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between Chat's two phases — an `auto` gutter would state the bug rather than fix it.
 
-The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column.
+The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column.
 
 The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md

@@ -18,7 +18,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟
 
 选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是 Chat 两种相位之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
 
-这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。
+这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。
 
 这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.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-09-broken-preset-roster-rows.md
-2026-08-09-broken-preset-roster-rows.md: 069585957d4d99598cc38e4a7c6bc8c8d82490ca
-2026-08-09-broken-preset-roster-rows.zh.md: 3bd60403edcce8ba7668c66561ce24c4d08b57e8
+2026-08-09-broken-preset-roster-rows.md: 4ae92f0b12f2ebede925d6047b358b8cafb10149
+2026-08-09-broken-preset-roster-rows.zh.md: 60d1ff533e3e968e04dad8526042fe4579752569

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md

@@ -12,7 +12,7 @@ With files as the only composition editor, hand-edit damage had two failure shap
 
 Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
 
-Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
+Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too; the reason itself has since moved off the card face onto the badge, see the note above), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
 
 ## Consequences
 
@@ -30,4 +30,4 @@ Surfaces split by their job: the management section renders broken rows as marke
 
 ## Alternatives considered
 
-Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.
+Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Partly superseded — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) now RESOLVES each such row without importing it, which is cheap enough to run on every read; the mount still owns the failures resolution cannot see. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
 
-界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
+界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给;原因本身此后已从卡片正面移到徽标上,见上述 note),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
 
 ## 后果
 
@@ -30,4 +30,4 @@ Status: implemented
 
 ## 曾考虑的替代方案
 
-隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。
+隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。已被部分取代——[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)现在会**解析**每一个这样的行而不 import 它,便宜到足以每次读取都跑;挂载仍然拥有解析看不见的那些失败。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.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-20-plugin-owned-shipped-preset-root.md
-2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d
-2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773
+2026-08-20-plugin-owned-shipped-preset-root.md: 5af1d213f62acfc92ba053e7246adf55e32b4c34
+2026-08-20-plugin-owned-shipped-preset-root.zh.md: e5f2e08e221258899433f363109fb385b7472dc6

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md

@@ -18,7 +18,7 @@ This completes the [per-session preset roster](../architecture/2026-08-03-per-se
 
 ## Testing
 
-`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
+`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets `system`-trusted and carrying no reason other than unresolved rows (proving the moved files resolve from the package). Health has since grown a module-resolution pass — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) — and a fixture base is not the install a shipped row's packages sit in, so the assertion names the reason it tolerates rather than requiring none, the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 ## 测试
 
-`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
+`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 为 `system` 信任、且除未解析行外不携带其他原因(证明搬移后的文件能从包内解析)。健康检查此后新增了一趟模块解析——见[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)——而 fixture 基准并不是内置行所引用的包所在的那个安装,因此该断言点名它容忍的原因,而不是要求一个都没有;内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md
+2026-08-21-inline-user-text-projection.md: deb0c54f41f950313246f3700d13b2a2c9f1b7ef
+2026-08-21-inline-user-text-projection.zh.md: f7b8dd1d825aae05440256df267ae5bf7404ec29

+ 32 - 0
.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md

@@ -0,0 +1,32 @@
+# Agent Note: Sent user text projects inline, and queue rows fold wire references
+
+Status: implemented
+
+English | [中文](2026-08-21-inline-user-text-projection.zh.md)
+
+## Problem
+
+Two display gaps in sent user text, both older than the Lexical composer. The user-bubble decorator (`projectUserText`, then private to `MessageItem`) split one message into plain runs and reference chips, but rendered every plain run through the block-level `MessageText` div — so a decorated single-line message broke into one line per run, and the single space between two adjacent tokens rendered as a whole blank line. Separately, the queue dock's read-only row printed `row.preview` verbatim, so a queued message carrying a chip showed the wire session form `@[查看并分析图片](dsh-session:InNlc3Npb24t…)` — the model-facing text, unreadable as a preview. The logged model text was correct in both cases (verified against the session log bytes); both defects were presentation only.
+
+## Decision
+
+One shared inline projection, `reference/user-text.tsx`, owns the display of sent user text and is consumed by the bubble and the queue row:
+
+- **Everything inline.** Plain runs render as `span`s; the block-level `MessageText` leaves the path entirely. White-space policy stays with the consumer: the bubble declares `pre-wrap` (real newlines survive), the queue preview keeps its `nowrap`/ellipsis single line — the shared spans pin neither.
+- **Wire session forms fold.** A new highest-precedence rule folds `@[label](dsh-session:…)` to a session chip showing the label (the source text stays on `title`). The existing rules — recall-associated exact labels, then bare `/name` / `@name` tokens by shape — follow at their old precedence, so the fold also shields the URI from the bare-token scan that would otherwise misread it as a file path.
+- **Queue edit stays literal.** The row's edit field exposes `row.text` unchanged: the user edits exactly what will be sent, and folding an editable surface would detach the visible text from the durable one.
+
+`queue-actions.e2e` locators moved from `getByText(…).locator('..')` to row-container matching (`li` with `hasText`): the projection adds one span layer, so a parent hop from the matched text no longer lands on the row.
+
+## Alternatives considered
+
+- **Carry a display text beside the queued model text**: rejected — it adds a wire/session field for a presentation concern and violates the single-truth rule; folding at render needs no new state.
+- **Fold inside the editor field too**: rejected — the edit target is the literal sent text; a folded editable view would let the user "edit" text that is not what gets sent.
+- **Fix only the bubble's blank line with CSS** (collapse empty runs): rejected — the runs were block-level by construction, and the queue gap needed the shared projection anyway.
+
+## Consequences
+
+- A decorated single-line message renders on one line; the bubble in the field report dropped from four visual lines (one blank) to its natural wrapped height.
+- Queue previews read as the composer showed them: label chips instead of `dsh-session:` URIs; the wire form also folds in the bubble if it ever reaches durable text.
+- `MessageItem` and `QueueDock` share one decoration vocabulary and stylesheet (`user-text.module.css`); the chip styles left `MessageItem.module.css`.
+- Tests: `user-text.client.spec` pins the inline guarantee (zero `div`s, whitespace-preserving runs) and every fold rule; the chat-view literal-text matcher followed the element change.

+ 32 - 0
.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: 已发送用户文本行内投影,Queue 行折叠 wire 引用
+
+Status: implemented
+
+[English](2026-08-21-inline-user-text-projection.md) | 中文
+
+## 问题
+
+已发送用户文本存在两个显示缺口,都早于 Lexical composer。用户气泡装饰器(`projectUserText`,当时是 `MessageItem` 的私有函数)把一条消息切成普通片段与引用 chip,但每个普通片段都经块级的 `MessageText` div 渲染——被装饰的单行消息因此被拆成每段一行,两个相邻 token 之间的单个空格更是渲染成一整行空白。另一处,queue dock 的只读行原样打印 `row.preview`,携带 chip 的排队消息因此显示 wire 会话形式 `@[查看并分析图片](dsh-session:InNlc3Npb24t…)`——面向模型的文本,作为预览不可读。两处的持久模型文本都是正确的(已对照会话日志字节核实);两个缺陷都纯属呈现层。
+
+## 决策
+
+由一个共享的行内投影 `reference/user-text.tsx` 负责已发送用户文本的显示,气泡与 queue 行共同消费:
+
+- **一切行内。** 普通片段渲染为 `span`;块级 `MessageText` 彻底退出该路径。换行策略归消费方:气泡声明 `pre-wrap`(真实换行保留),queue 预览保持 `nowrap`/省略号单行——共享 span 两者都不钉死。
+- **wire 会话形式折叠。** 新增最高优先级规则把 `@[label](dsh-session:…)` 折叠为显示标签的会话 chip(原文保留在 `title`)。既有规则——recall 关联的精确标签、按形状识别的裸 `/name` / `@name` token——按原优先级跟随,因此折叠同时挡住了裸 token 扫描(否则它会把 URI 误读成文件路径)。
+- **Queue 编辑态保持原文。** 行的编辑框展示未经处理的 `row.text`:用户编辑的正是将要发送的内容,折叠一个可编辑表面会让可见文本与持久文本脱钩。
+
+`queue-actions.e2e` 的定位器从 `getByText(…).locator('..')` 改为行容器匹配(带 `hasText` 的 `li`):投影多了一层 span,从命中文本向上跳一级不再落在行元素上。
+
+## 曾考虑的替代方案
+
+- **在排队模型文本旁另存一份显示文本**:否决——为呈现关切增加 wire/会话字段,违背单一真源;渲染期折叠不需要新状态。
+- **编辑框内也折叠**:否决——编辑对象就是字面发送文本,可编辑表面折叠会让用户"编辑"一段并非实际发送的文本。
+- **仅用 CSS 修气泡空行**(折叠空片段):否决——片段的块级性是结构性的,且 queue 缺口本来就需要共享投影。
+
+## 后果
+
+- 被装饰的单行消息渲染为一行;现场报告中的气泡从四视觉行(含一空行)降到自然换行高度。
+- Queue 预览按 composer 中的样子呈现:标签 chip 取代 `dsh-session:` URI;wire 形式若进入持久文本,气泡同样折叠。
+- `MessageItem` 与 `QueueDock` 共享一套装饰词汇与样式表(`user-text.module.css`);chip 样式移出 `MessageItem.module.css`。
+- 测试:`user-text.client.spec` 钉住行内保证(零 `div`、片段保留空白)与每条折叠规则;chat-view 的字面文本匹配器随元素变化更新。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.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-26-composer-gesture-editable-gate.md
+2026-08-26-composer-gesture-editable-gate.md: 9b01a79d8d8a546641eaf87040d7a19fc5b4d5bc
+2026-08-26-composer-gesture-editable-gate.zh.md: b5dc8d36c7b195a152d32e92fac7ae36eea880d9

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e gestures gate on the contenteditable attribute
+
+Status: implemented
+
+English | [中文](2026-08-26-composer-gesture-editable-gate.zh.md)
+
+## Problem
+
+Two Playwright gesture semantics silently changed when the composer became a Lexical `contenteditable` `<div>`, and both bit only under CI load. While the input machine is adjudicating or submitting a send — and in every locked state — the composer renders read-only by flipping `contenteditable` to `"false"` on the same element. On that element `fill()` throws immediately (`Element is not an <input>, <textarea> or [contenteditable] element`) instead of waiting through actionability, and `expect.poll(() => input.isEnabled())` is a no-op guard: Playwright's enablement check ignores both `aria-disabled` and `contenteditable` on a `<div>`, so it reports `true` throughout the read-only window. The exposed race is only a few frames wide — the permission-policy scenario stayed green for weeks until a Remote-routed subagent refactor stretched submit settling enough for CI to land inside it.
+
+## Decision
+
+Composer e2e gestures go through `writeComposerDraft` in `apps/web/tests/support.ts`, which waits for the editable attribute on the gesture's own target (`input.and(page.locator('[contenteditable="true"]'))`) before acting and replaces the draft with per-key strokes. Scenario code that must wait for the composer to reopen after a submit gates on the `contenteditable` attribute, never on `isEnabled()`.
+
+## Alternatives considered
+
+- **Waiting inside each scenario** instead of inside the helper: rejected — every new scenario re-discovers the trap the hard way, and the fix that motivated this note was already the second such site.
+- **Keeping `fill()` and polling `aria-disabled`** before each call: rejected — it leaves the dropped-edit race `fill()` has directly after trigger-menu and chip interactions (Lexical's internal selection lags the DOM selection inside one task), which the per-key helper also covers.
+- **Making the product surface tolerate `fill()`** (accepting synthetic edits while read-only): rejected — the read-only window is deliberate UI truth during submit adjudication; loosening it for tests would change user-visible behavior.
+
+## Consequences
+
+- A bare `input.fill(...)` against `[data-composer-input]` is a latent CI race even when it passes locally; the helper is the supported gesture.
+- `isEnabled()` on the composer asserts nothing. Existing polls of it guard nothing and read as coverage they do not provide.
+- A running turn by itself keeps the composer editable — that is what queueing types into — so the gate waits only through submit adjudication and locked states, not for turn completion.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e 手势以 contenteditable 属性为门
+
+Status: implemented
+
+[English](2026-08-26-composer-gesture-editable-gate.md) | 中文
+
+## 问题
+
+composer 变为 Lexical `contenteditable` `<div>` 后,两个 Playwright 手势语义悄然改变,且都只在 CI 高负载下咬人。输入机在裁决或发送一次提交期间——以及所有 locked 状态下——composer 通过把同一元素的 `contenteditable` 翻成 `"false"` 呈现只读。在该元素上 `fill()` 立即抛错(`Element is not an <input>, <textarea> or [contenteditable] element`)而不再经 actionability 等待;`expect.poll(() => input.isEnabled())` 则是无效护栏:Playwright 的 enablement 检查对 `<div>` 同时无视 `aria-disabled` 与 `contenteditable`,整个只读窗口内一律报 `true`。暴露的竞态只有几帧宽——permission-policy 场景绿了数周,直到 subagent 控制 Remote 化把提交 settle 拉长,CI 才落进窗口。
+
+## 决策
+
+composer 的 e2e 手势统一走 `apps/web/tests/support.ts` 的 `writeComposerDraft`:动作前在手势自身的目标上等待可编辑属性(`input.and(page.locator('[contenteditable="true"]'))`),再以逐键击键替换草稿。场景代码若需等待提交后 composer 重新开放,一律以 `contenteditable` 属性为门,永不使用 `isEnabled()`。
+
+## 曾考虑的替代方案
+
+- **在各场景内各自等待**而不是收进 helper:否决——每个新场景都会以最痛的方式重新发现这个陷阱,而促成本 note 的修复本身已是第二个踩点。
+- **保留 `fill()`、每次调用前 poll `aria-disabled`**:否决——这仍留着 `fill()` 在触发菜单与 chip 交互之后的丢编辑竞态(单 task 内 Lexical 内部 selection 落后于 DOM selection),逐键 helper 同时覆盖了它。
+- **让产品表面容忍 `fill()`**(只读期间接受合成编辑):否决——只读窗口是提交裁决期间刻意的 UI 事实;为测试放松它会改变用户可见行为。
+
+## 后果
+
+- 对 `[data-composer-input]` 裸写 `input.fill(...)` 即使本地全绿也是潜伏的 CI 竞态;helper 是受支持的手势。
+- 对 composer 调用 `isEnabled()` 断言不了任何东西。既有的此类 poll 不护任何路径,却读起来像提供了覆盖。
+- turn 运行本身保持 composer 可编辑——排队输入正是打进这里——因此该门只等待提交裁决与 locked 状态,不等待 turn 完成。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.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-26-pi-ai-grant-payload-json-image.md
+2026-08-26-pi-ai-grant-payload-json-image.md: 9eb5e5ffd9ccbc18d409bd50e05061006bdca094
+2026-08-26-pi-ai-grant-payload-json-image.zh.md: 8329b4acf062a073dfe78ec10fe03852e7a64fd9

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md

@@ -0,0 +1,29 @@
+# Agent Note: pi-ai grant payloads store their JSON image
+
+Status: implemented
+
+English | [中文](2026-08-26-pi-ai-grant-payload-json-image.zh.md)
+
+## Problem
+
+A GitHub Copilot sign-in against github.com failed at its commit step: `credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`. pi-ai's Copilot credential carries its optional members as explicit `undefined` (`enterpriseUrl: undefined` when no Enterprise domain was given — idiomatic JavaScript that `JSON.stringify` would simply drop), and `llm-pi-ai`'s store bridge committed the credential object verbatim as the grant payload. The credential store's validator rightly refuses `undefined` as unrepresentable, so every grant whose flow left an optional member unset failed to store, and the sign-in reported failure after the provider had already authorized it.
+
+## Decision
+
+`toRecord` in `packages/llm/llm-pi-ai/src/auth.ts` stores the JSON image of a grant credential: `jsonImage` drops explicitly-undefined members of plain objects and renders undefined array entries as `null`, exactly as `JSON.stringify` would. Everything else — non-finite numbers, foreign-prototype objects — passes through untouched, so a genuinely unstorable value still fails loud at the store's validator rather than being silently reshaped. Reading back is unchanged: an absent member and an explicitly-undefined one are indistinguishable to pi-ai's consumers, which access optional members by property read.
+
+## Testing
+
+`tests/auth.spec.ts` writes the Copilot-shaped grant (explicit `undefined` member, nested drop, array hole) through the real `LocalCredentialProvider` and asserts the stored payload is the JSON image; a second case proves the fail-loud path survives by committing a `Date`-valued member and asserting the store's refusal reaches the caller.
+
+## Alternatives considered
+
+**`JSON.parse(JSON.stringify(credential))`.** Rejected: it also renders `NaN`/`Infinity` as `null` and runs `toJSON` methods, silently reshaping exactly the values the strict validator exists to refuse loudly.
+
+**Relaxing the store validator to skip `undefined` members.** Rejected: the seam stores payloads it never reads or reshapes, and every producer relies on byte-faithful round-trips; normalization belongs to the producer that knows its library's idiom, not to the store every plugin shares.
+
+**Fixing pi-ai upstream to omit unset members.** Out of this repository's hands and version-fragile: any future flow reintroducing the idiom would break sign-in again. The bridge owning the translation makes the harness robust against the whole class.
+
+## Consequences
+
+Grants from every pi-ai flow store regardless of which optional members the flow left unset. The bridge now owns a one-way normalization: a payload read back lacks members that were explicitly `undefined` at write time, which is indistinguishable from their absence for property access, and remains the documented JSON semantics.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: pi-ai grant payload 落盘其 JSON 像
+
+Status: implemented
+
+[English](2026-08-26-pi-ai-grant-payload-json-image.md) | 中文
+
+## Problem
+
+一次面向 github.com 的 GitHub Copilot 登录在提交环节失败:`credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`。pi-ai 的 Copilot 凭据以显式 `undefined` 携带可选成员(未填 Enterprise 域名时为 `enterpriseUrl: undefined`——这是 `JSON.stringify` 会直接丢弃的 JavaScript 惯用写法),而 `llm-pi-ai` 的存储桥接把凭据对象原样作为 grant payload 提交。凭据存储的校验器正当地拒绝 `undefined` 为不可表示,于是所有流程留有未填可选成员的 grant 都无法落盘,提供方已经完成授权之后登录却报失败。
+
+## Decision
+
+`packages/llm/llm-pi-ai/src/auth.ts` 的 `toRecord` 改为落盘 grant 凭据的 JSON 像:`jsonImage` 丢弃普通对象里显式为 undefined 的成员,把数组中的 undefined 条目渲染为 `null`,与 `JSON.stringify` 完全一致。其余一切——非有限数、异种原型对象——原样透传,因此真正不可存储的值仍会在存储校验器处大声失败,而不是被静默改写。读回不变:成员缺失与显式 undefined 对以属性读取访问可选成员的 pi-ai 消费方不可区分。
+
+## Testing
+
+`tests/auth.spec.ts` 经真实 `LocalCredentialProvider` 写入 Copilot 形状的 grant(显式 `undefined` 成员、嵌套丢弃、数组空洞),断言落盘 payload 为 JSON 像;第二个用例提交带 `Date` 成员的 grant 并断言存储的拒绝到达调用方,证明 fail-loud 路径仍在。
+
+## Alternatives considered
+
+**`JSON.parse(JSON.stringify(credential))`。**否决:它还会把 `NaN`/`Infinity` 渲染为 `null` 并执行 `toJSON` 方法,把严格校验器本要大声拒绝的值静默改写掉。
+
+**放宽存储校验器、跳过 undefined 成员。**否决:seam 存储的 payload 它从不读取或改写,所有生产方都依赖逐字节往返;归一化属于了解自家库惯用法的生产方,而不是所有插件共享的存储。
+
+**在 pi-ai 上游修掉未填成员。**不在本仓库掌控内且随版本脆弱:将来任何流程重新引入该惯用法都会再次弄坏登录。由桥接层拥有这次翻译,让 harness 对整类问题免疫。
+
+## Consequences
+
+所有 pi-ai 流程的 grant 无论留空哪些可选成员都能落盘。桥接层现在拥有一次单向归一化:写入时显式为 undefined 的成员在读回时缺失,这对属性访问不可区分,且正是文档化的 JSON 语义。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.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-26-question-drafts-survive-session-switch.md
+2026-08-26-question-drafts-survive-session-switch.md: 2279c4efd5e79c51a0e2e2347f21fae4b0a47b7a
+2026-08-26-question-drafts-survive-session-switch.zh.md: af275a704bef723cb3ed6fde83138a2be5b5cb3f

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md

@@ -0,0 +1,39 @@
+# Agent Note: Question drafts survive Session switches
+
+Status: implemented
+
+English | [中文](2026-08-26-question-drafts-survive-session-switch.zh.md)
+
+## Problem
+
+`conversation.composer` is a strict Session-scoped slot, so selecting another Session unmounts its question entry. The generic `QuestionFlow` kept its current question index, selected labels, custom text, and skip flags in React component state. A still-pending request therefore returned with empty answers after an A → B → A Session switch even though the pending carrier remained owned by Session A.
+
+The draft is transient presentation state: it must follow its Session within the current page, but it must not become mutable state on the pending business carrier or a user preference synchronized through Host settings.
+
+## Decision
+
+The question entry declares a non-persisted `createQuestionDraftStore` handle when it registers into `conversation.composer`. The renderer owns one instance per Session scope and retains that instance across selection changes, so remounting the same Session reads the same progress.
+
+The store holds at most one request identity and one progress value: current question index plus one selected/custom/skipped draft per question. `QuestionFlow` reads the stored value only when the local pending-request key and question count match. A new request therefore renders empty immediately and its first write atomically replaces the older value instead of accumulating request records. Successful answer and cancellation settlements clear only their matching request key, so a stale completion cannot erase a later draft.
+
+Busy state, failure feedback, collapse state, and focus bookkeeping remain component-local because they describe the mounted interaction rather than the unfinished answer. The `plan-review` presentation has no multi-question draft and does not read the store.
+
+This realizes the existing [Session-scope rule](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) that remount-surviving state belongs in a Session-bound source, while retaining the [Host-backed preference decision](2026-08-06-host-backed-web-preferences.md): drafts remain page-local and never enter settings, `localStorage`, or disk. The answer semantics from [multi-select custom composition](2026-07-30-multi-select-custom-answer-composition.md) are unchanged.
+
+## Testing
+
+The store test pins keyed replacement and stale-cleanup isolation. The component test unmounts and remounts the strict entry over one store instance and requires its page, selected option, and custom text to return. The keyless assembled Web scenario types both answer forms, switches to a new Session, returns to the waiting Session, snapshots the restored composer, and submits the restored values through the real question waterfall.
+
+## Alternatives considered
+
+**Keep the state in `QuestionFlow`.** Rejected because a strict Session switch deliberately destroys that React instance; a component-local key cannot outlive the unmount it is intended to identify.
+
+**Put mutable drafts on `PendingQuestion`.** Rejected because the carrier represents pending request settlement, not React presentation state, and mutations there would bypass the Slot store's subscribed read/write surface and lifecycle ownership.
+
+**Use a module-level map keyed by Session and request.** Rejected because plugin reload and Session pruning would not own its disposal, and completed request entries could accumulate independently of the renderer's scope lifecycle.
+
+**Persist drafts through Host settings or browser storage.** Rejected because switching Sessions within one page needs remount continuity, not cross-page or cross-process durability. Persistence would synchronize transient answer text beyond the interaction that owns it.
+
+## Consequences
+
+Unsubmitted generic-question answers survive ordinary Session navigation in the current page, including the current question and explicit skips. They still reset after a page reload, Session-scope prune, or replacement pending-request identity. The per-Session memory cost is bounded to one request progress value and is released with the Slot store's Session scope.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 提问草稿在 Session 切换后保留
+
+Status: implemented
+
+[English](2026-08-26-question-drafts-survive-session-switch.md) | 中文
+
+## Problem
+
+`conversation.composer` 是严格按 Session 划分 scope 的 slot,因此选择另一个 Session 会卸载其提问条目。通用 `QuestionFlow` 把当前题号、已选标签、自定义文本和跳过标记保存在 React 组件状态中。因此,即使待处理载体仍归 Session A 所有,一个仍在等待的请求经过 A → B → A 的 Session 切换后,也会以空答案重新出现。
+
+草稿是临时呈现状态:它必须在当前页面内跟随所属 Session,但不能变成待处理业务载体上的可变状态,也不能成为通过 Host settings 同步的用户偏好。
+
+## Decision
+
+提问条目注册到 `conversation.composer` 时声明一个非持久化的 `createQuestionDraftStore` handle。renderer 为每个 Session scope 拥有一个实例,并在选择切换期间保留该实例,因此重新挂载同一 Session 时会读到相同进度。
+
+store 最多保存一个请求标识和一个进度值:当前题号,以及每道题各一份 selected/custom/skipped 草稿。只有本地待处理请求 key 与题目数量都相符时,`QuestionFlow` 才读取已存值。因此,新请求会立即渲染为空,并在首次写入时原子替换旧值,而不会累积请求记录。成功回答和取消落定后只清除与自身相符的请求 key,因此过期的完成动作不会删除较新的草稿。
+
+忙碌状态、失败提示、折叠状态和焦点记录仍留在组件本地,因为它们描述当前已挂载交互,而不是未完成的答案。`plan-review` 呈现界面没有多题草稿,也不读取该 store。
+
+这落实了既有的 [Session scope 规则](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md):需要跨重新挂载保留的状态应归 Session 绑定的数据源;同时保留[由 Host 持久化偏好的决策](2026-08-06-host-backed-web-preferences.zh.md):草稿仍只存在于当前页面,从不进入 settings、`localStorage` 或磁盘。[多选自定义答案组合](2026-07-30-multi-select-custom-answer-composition.zh.md)规定的答案语义保持不变。
+
+## Testing
+
+store 测试固定按 key 替换和过期清理隔离。组件测试在同一个 store 实例上卸载并重新挂载严格 Session 条目,并要求题号、已选选项和自定义文本全部恢复。无密钥的组装 Web 场景会输入两种答案、切换到新 Session、返回仍在等待的 Session、对恢复后的编辑器生成快照,再经真实提问 waterfall 提交恢复的值。
+
+## Alternatives considered
+
+**继续把状态留在 `QuestionFlow`。** 不采用,因为严格 Session 切换会刻意销毁该 React 实例;组件本地 key 无法比其试图标识的卸载过程活得更久。
+
+**把可变草稿放进 `PendingQuestion`。** 不采用,因为载体表示待处理请求的落定过程,而不是 React 呈现状态;在其中做变更还会绕过 Slot store 提供的订阅读写界面和生命周期归属。
+
+**使用按 Session 和请求建立索引的模块级 map。** 不采用,因为 plugin 重载与 Session 裁剪不拥有其清理过程,已完成请求的条目还可能脱离 renderer 的 scope 生命周期不断累积。
+
+**通过 Host settings 或浏览器存储持久化草稿。** 不采用,因为同一页面内切换 Session 需要的是跨重新挂载连续性,而不是跨页面或跨进程耐久性。持久化会把临时答案文本同步到拥有它的交互之外。
+
+## Consequences
+
+未提交的通用提问答案现在能在当前页面的普通 Session 导航中保留,包括当前题号和显式跳过状态。刷新页面、Session scope 被裁剪或待处理请求标识被替换后,草稿仍会重置。每个 Session 的内存成本被限制为一个请求进度值,并随 Slot store 的 Session scope 一起释放。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md
-2026-07-06-approval-seam.md: cbb1b1cb1fef0f6ed1db8e3e0eb1ea01a5834f53
-2026-07-06-approval-seam.zh.md: 29e23f81827200cbbbe0d6a78216ff1f1d4b52ae
+2026-07-06-approval-seam.md: 6e94ed945b4c568bd3c82c8e590736d8d1c27606
+2026-07-06-approval-seam.zh.md: 26447522ecf1d93a09944c0d2eb9574f1e0f9cb2

+ 1 - 1
.agents/notes/implemented/feature/2026-07-06-approval-seam.md

@@ -55,7 +55,7 @@ After validation and a successful `approval/asked` append, the service resolves
 
 Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates.
 
-`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments.
+`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `ToolCallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments.
 
 #### Ask routing in dsh-tools
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md

@@ -55,7 +55,7 @@ tool/result      "escalated" — this one call ran under the wider mode; the gra
 
 应答者是 `approval/request` waterfall 监听器。零监听器会直接落到 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器会随其 fiber 一同 dispose(资源释放),因此卸载通道后,请求会在故障时默认被拒绝。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。
 
-`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。
+`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `ToolCallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。
 
 #### dsh-tools 中的 Ask 路由
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-agent-teams.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-05-agent-teams.md
-2026-08-05-agent-teams.md: d37777a0cca467edcec5d38999aee53aaf14dc36
-2026-08-05-agent-teams.zh.md: 91571bd28aa44a21bebe1ef9cfc8434c2e8850a3
+2026-08-05-agent-teams.md: 9924550a04b636535ce1daa329865beb1c9e951d
+2026-08-05-agent-teams.zh.md: 91ae807f3005c173a61f9fc32661d2b650692a04

+ 3 - 3
.agents/notes/implemented/feature/2026-08-05-agent-teams.md

@@ -10,7 +10,7 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo
 
 All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary.
 
-The model-visible Team tools remain opt-in so the default tool catalog and simple-task behavior do not change. An explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown.
+Agent Teams needs an explicit source-checkout composition before its public contracts are stable enough for released CLI or Web bundles. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown.
 
 ## Decision
 
@@ -54,7 +54,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
 
 **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose.
 
-**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. Explicit composition keeps model-visible ownership unambiguous without changing shipped requests.
+**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. A private profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs.
 
 **Use an in-memory board and mailbox.** Rejected because child settlement, HMR, and process interruption would lose accepted coordination state and make retries ambiguous.
 
@@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
 
 ## Testing
 
-Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless headless Loader snapshot assembles the real Team plugins and records teammate creation, peer mail, dependent tasks, waiting, and Lead aggregation.
+Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs.
 
 ## Consequences
 

+ 3 - 3
.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md

@@ -10,7 +10,7 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll
 
 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。
 
-面向模型的 Team 工具保持显式启用,使默认工具目录与简单任务行为不变。显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。
+在公开约定稳定到足以进入已发布 CLI 或 Web bundle 前,Agent Teams 需要显式的源码 checkout 组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。
 
 ## Decision
 
@@ -54,7 +54,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
 
 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。
 
-**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。显式组合可以保持面向模型的归属明确,同时不改变默认 request
+**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。私有 profile bundle 会插入 Team 并禁用旧 control,同时不向已发布依赖图添加 Team 包
 
 **使用内存 task board 与 mailbox。** 拒绝,因为 child settlement、HMR 与进程中断会丢失已接受协调状态,并让重试变得含糊。
 
@@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
 
 ## Testing
 
-Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless headless Loader 快照会组合真实 Team 插件,并记录 teammate 创建、peer mail、依赖任务、等待与 Lead 汇总
+Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出
 
 ## Consequences
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-06-agent-teams-web.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-06-agent-teams-web.md
+2026-08-06-agent-teams-web.md: 5002146f7db67f7199102b286a85da0148864fa9
+2026-08-06-agent-teams-web.zh.md: d05f22141811f35ba52a92a02c8153b024ef8d8d

+ 43 - 0
.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md

@@ -0,0 +1,43 @@
+# Agent Note: Experimental Agent Teams Web controls
+
+Status: implemented
+
+English | [中文](2026-08-06-agent-teams-web.zh.md)
+
+## Problem
+
+The durable Agent Teams runtime owns roster, mailbox, and task state but exposes only model tools and Host service methods. Web users need to inspect teammate activity, manage shared tasks with the same compare-and-set rules, and open a teammate conversation. Agent Teams is still experimental, so these capabilities must not add Team-specific contracts or dependencies to the stable API Proxy, Session Controller, Client UI packages, or Web bundle.
+
+## Decision
+
+The private `ctx.agentTeams` service owns generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside its domain operations. The Team package owns the browser-safe view and mutation-result types. Views contain roster and current task state but omit pending mailbox content and deleted task tombstones. Create and update rejections cross Remote as closed business results; stale update revisions preserve `team-task-conflict`, while other Team rejections preserve `team-rejected`. Unexpected failures remain ordinary `RemoteResult` failures.
+
+`@deepseek-ai/dsh-experimental-client-ui-agent-team` mounts the `@deepseek-ai/dsh-experimental-agent-team/remote` contribution through the stable `ctx.remote` service, then consumes the generated `ctx.remote.agentTeams` methods without an additional Client result wrapper. It displays roster status, model and diagnostics and supports task create, edit, dependency update, assignment, completion, reopen, and deletion. Every update sends the displayed revision. Each create or update owns an independent pending token, invalidates older refreshes before starting, and reloads the complete Team view after success. A conflict asks the user to review only after its reload succeeds; a reload failure remains visible. Overlapping refreshes publish only the latest request for the selected Session.
+
+Teammate navigation uses the existing `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address without a Team tag. The UI refreshes the direct-child catalog, rechecks the selected Session, and opens the addressed conversation. History and later human prompts follow the stable Subagent path; the Team mailbox remains reserved for Team peer delivery from Team tools.
+
+`@deepseek-ai/dsh-experimental-agent-team-web-profile` inserts only the UI after the stable Web bundle. It is applied alongside the Host-side `@deepseek-ai/dsh-experimental-agent-team-profile`, which already inserts `ctx.agentTeams` and the model tools. Neither stable bundle contains disabled Team rows or dependencies.
+
+Stable Web presets still register continuable Subagent controls inside their preset scope. Top-level Agent Teams profile overrides cannot replace those registrations, so this experimental composition may expose both the Team roster and legacy child controls. A Team-aware Web preset is deferred; the [Web profile README](../../../../packages/experimental/agent-team-web-profile/README.md#known-limitations-and-deferred-work) owns the current limitation.
+
+## Boundaries
+
+The Web UI has no mailbox timeline, worktree or Git controls, teammate creation, rename, deletion, interruption, or automatic merge behavior. It does not infer filesystem authority from task ownership or write scopes. A human continuation after teammate navigation is an ordinary addressed-child prompt, not a Team mailbox message.
+
+## Alternatives considered
+
+**Extend the legacy API Proxy Team RPC map.** Rejected because it would put an experimental domain in a stable wire package and duplicate the generated Remote vocabulary and validation.
+
+**Introduce a separate browser Remote service.** Rejected because the methods have no state, lifecycle, or policy owner distinct from `ctx.agentTeams`; a second Cordis service would duplicate Team injection and require another package for the same Typert namespace.
+
+**Add Team metadata to the stable Subagent address and prompt routing.** Rejected because ordinary child navigation already identifies the conversation. A Team tag would couple stable Client and Subagent contracts to experimental mailbox policy.
+
+**Put disabled Team rows in the stable Web bundle.** Rejected because a disabled row still creates release dependencies and makes the experimental package part of shipped composition.
+
+## Testing
+
+Team-service unit tests, generation, and a plain-Node built-artifact smoke verify the direct Remote methods, error mapping, and exported descriptors. Client typechecking and browser component tests cover the mounted namespace, Lead routing, raw generated results, every task action, independent pending operations, complete-board reloads, successful and failed conflict reloads, stale async results, navigation, disposal, and status or error presentation. A Web end-to-end test asserts that its overlay equals both shipped experimental profile layers, then exercises the real Host Remote flow.
+
+## Consequences
+
+The Team service is the single Cordis owner for both domain state and the Remote operations that expose selected Team values. The stable API Proxy, Session Controller, Client UI packages, and Web bundle remain Team-agnostic. Source-checkout users must add two ordered experimental profile layers to a Web profile. Promotion renames the experimental npm packages but does not require a new generated namespace.

+ 43 - 0
.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md

@@ -0,0 +1,43 @@
+# Agent Note:实验性 Agent Teams Web 控件
+
+状态:已实现
+
+[English](2026-08-06-agent-teams-web.md) | 中文
+
+## 问题
+
+持久 Agent Teams runtime 负责 roster、mailbox 与 task 状态,但只提供模型工具和 Host service method。Web 用户需要查看 teammate 活动、按同样的 compare-and-set 规则管理共享任务,并打开 teammate 会话。Agent Teams 仍处于实验阶段,因此这些能力不能向稳定 API Proxy、Session Controller、Client UI package 或 Web bundle 增加 Team 专用 contract 或依赖。
+
+## 决策
+
+私有 `ctx.agentTeams` service 除 domain operation 外,还直接负责生成式 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。Team package 负责浏览器安全的 view 与 mutation-result type。View 包含 roster 与当前 task 状态,但不包含 pending mailbox 内容或已删除 task tombstone。Create 与 update rejection 通过封闭 business result 跨越 Remote;过期的 update revision 保留为 `team-task-conflict`,其他 Team rejection 保留为 `team-rejected`。意外 failure 仍是普通 `RemoteResult` failure。
+
+`@deepseek-ai/dsh-experimental-client-ui-agent-team` 通过稳定 `ctx.remote` service 挂载 `@deepseek-ai/dsh-experimental-agent-team/remote` contribution,随后直接消费生成式 `ctx.remote.agentTeams` method,不增加 Client result 包装层。它展示 roster status、model 与 diagnostics,并支持 task create、edit、dependency update、assignment、completion、reopen 与 deletion。每次 update 都发送当前显示的 revision。每个 create 或 update 都独立持有 pending token,在开始前使更早的 refresh 失效,并在成功后重新读取完整 Team view。Conflict 仅在其 reload 成功后要求用户检查;如果重新读取失败,则保留该错误。重叠 refresh 只发布所选 Session 的最新请求。
+
+Teammate navigation 使用既有 `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address,不带 Team tag。UI 刷新直接 child catalog、再次检查所选 Session,然后打开 addressed conversation。History 与后续人类 prompt 使用稳定 Subagent 路径;Team mailbox 只用于 Team 工具发起的 Team peer delivery。
+
+`@deepseek-ai/dsh-experimental-agent-team-web-profile` 在稳定 Web bundle 之后只插入 UI。它与 Host 侧 `@deepseek-ai/dsh-experimental-agent-team-profile` 一起应用,后者已经插入 `ctx.agentTeams` 与模型工具。两个稳定 bundle 都不包含禁用的 Team row 或依赖。
+
+稳定 Web preset 仍会在自身 preset scope 内注册 continuable Subagent control。顶层 Agent Teams profile override 无法替换这些 registration,因此该实验性 composition 可能同时暴露 Team roster 与 legacy child control。Team-aware Web preset 暂缓实现;[Web profile README](../../../../packages/experimental/agent-team-web-profile/README.zh.md#known-limitations-and-deferred-work)负责记录当前限制。
+
+## 边界
+
+Web UI 不提供 mailbox timeline、worktree 或 Git control、teammate creation、rename、deletion、interrupt 或自动 merge。它不会从 task ownership 或 write scope 推断文件系统权限。导航到 teammate 后的人类 continuation 是普通 addressed-child prompt,不是 Team mailbox message。
+
+## 考虑过的替代方案
+
+**扩展 legacy API Proxy Team RPC map。** 拒绝,因为这会把实验性 domain 放入稳定 wire package,并重复生成式 Remote vocabulary 与 validation。
+
+**引入独立的浏览器 Remote service。** 拒绝,因为这些 method 没有区别于 `ctx.agentTeams` 的状态、lifecycle 或 policy owner;第二个 Cordis service 会重复 Team injection,并要求另一个 package 提供同一个 Typert namespace。
+
+**向稳定 Subagent address 与 prompt routing 添加 Team metadata。** 拒绝,因为普通 child navigation 已经标识会话;Team tag 会让稳定 Client 与 Subagent contract 耦合实验性 mailbox policy。
+
+**在稳定 Web bundle 中加入禁用 Team row。** 拒绝,因为禁用 row 仍会产生 release 依赖,并让实验性 package 成为随附 composition 的一部分。
+
+## 测试
+
+Team service 单元测试、生成流程与 plain-Node built-artifact smoke 校验直接 Remote method、error mapping 与导出 descriptor。Client typecheck 与浏览器 component test 覆盖挂载 namespace、Lead routing、原始生成式 result、所有 task action、独立 pending operation、完整 task board reload、成功及失败的 conflict reload、陈旧 async result、navigation、dispose 与状态或错误呈现。Web 端到端测试先断言 overlay 等于两个随附实验性 profile 层,再运行真实 Host Remote flow。
+
+## 后果
+
+Team service 是 domain state 与公开选定 Team value 的 Remote operation 的唯一 Cordis owner。稳定 API Proxy、Session Controller、Client UI package 和 Web bundle 保持 Team 无关。源码 checkout 用户必须向 Web profile 添加两个有序 experimental profile 层。Promotion 会重命名实验性 npm package,但不要求新的生成式 namespace。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.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-18-conversation-adaptive-content-width.md
+2026-08-18-conversation-adaptive-content-width.md: 9b0e7afbded296b60b7c283030a537321b36684e
+2026-08-18-conversation-adaptive-content-width.zh.md: f1f424cf9d4ac087518e9f79a365d0052069b999

+ 35 - 0
.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md

@@ -0,0 +1,35 @@
+# Agent Note: Adaptive and drag-resizable conversation content width
+
+Status: implemented
+
+English | [中文](2026-08-18-conversation-adaptive-content-width.zh.md)
+
+## Problem
+
+The conversation column's shared width axis (`--dsh-chat-content-width`) was the fixed figma constant 748px. On wide monitors (a 4000px display leaves a ~3500px column) the transcript occupied under a quarter of the column with dead margins on both sides. Every derived surface — the input card (W + 32px), dock cards, takeover panels, StatsLine, the back-to-bottom padding formula — rides this one variable, so any change had to keep the whole column's alignment relations intact. Alongside the adaptive default, users asked for direct control: hover the transcript's side margins to get a col-resize cursor and drag either edge, with both edges moving symmetrically.
+
+## Decision
+
+**The axis becomes a user override over an adaptive clamp.** `ConversationRoot.module.css` declares `--dsh-chat-content-width: var(--dsh-chat-user-width, clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px))`. The floor is 680px — one step under the figma 748px, after full-width reading felt wide on every screen — wider columns take 64% of the column, and 920px caps line length for readability (~113 characters at the base font). A dragged preference replaces the adaptive term wholesale.
+
+**The column width is published by a ResizeObserver, not container queries.** The component publishes the root's `offsetWidth` as `--dsh-conversation-column-width` in px (the same callback-ref pattern as the existing composer seat height observer). `container-type: inline-size` was rejected: the conversation subtree contains portal-free `position: fixed` descendants (Tooltip, Menu, JsonTree copy anchors) whose viewport anchoring a size container would capture — the same class of trap the `.composerHero` comment records for transforms. A bare `%` in the variable was rejected because custom-property percentages resolve per consumer against different containing blocks, breaking the input-card = W + 32px invariant; `vw` was rejected because the column is not the viewport (sidebar fold changes the column only).
+
+**Drag handles are 40px strips beside the transcript, symmetric by construction.** Each strip's inner edge sits 24px outside the content column and extends 40px outward, with the outer edge clamped to keep a 24px safe zone from the column edges (24 + 40 + 24 = the 88px-per-side budget below); when the margin cannot fit inset + strip + safe zone the computed width goes negative and the strip resolves to zero. Both handles write the one centered width — outward travel widens by 2× the pointer distance — reusing AppFrame's DragHandle capture model (pointer capture + rAF throttle + drag-start snapshot); only a gesture with actual pointer travel commits to storage, so a bare press-and-release on a window-clamped width cannot overwrite the wider stored preference. The hover indicator is a 3px glow riding the pointer's Y (published as `--dsh-width-handle-pointer-y` on pointermove): a 24px solid core fading over 40px each side, in the scrollbar hover tint because border-token alphas disappear against the base fill. Handles render only in the active phase; views that elect a composer overlay (trajectory) hide them, and the header lifts above them (z-index 9) to stay clickable.
+
+**The preference persists in `localStorage` (`dsh.conversation.contentWidth`) and clamps without rewriting.** The displayed width re-clamps to `[640px, column − 176px]` when the column shrinks (88px per side keeps the handles fully placeable — a wider drag would push its own handles off the column), but the stored preference survives — widening the window restores it, the same rule AppFrame's sidebar drag follows. The handle carries no reset affordance and no tooltip; a stored preference is only ever replaced by another drag.
+
+**The user bubble cap follows the axis.** `min(525px, 82%)` becomes `min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)` (0.702 = 525/748, the figma bubble share of the figma column) in both `ui-conversation` MessageItem and the symmetric `ui-goal` command bubble, so bubbles scale with the column. The 748px fallback covers mounts outside the conversation column.
+
+## Alternatives considered
+
+**Raise the constant (748 → ~850).** Rejected: every mid-width window's line length grows too, hurting readability where most users live.
+
+**Wide-content bleed (code blocks and tool cards break out of the prose column).** Best reading ergonomics but touches MarkdownText and every tool card's layout; deferred as a possible second phase.
+
+**A settings-backed "wide mode" toggle.** Adds a persistent settings surface for what drag already covers; not needed.
+
+**A 12px handle strip beside the input card.** Shipped first and unusable in practice: on a wide screen the strip was a sliver in a thousand-plus pixels of margin, and the sticky input card overlapped it. Replaced by the 40px strip anchored to the glow line's position.
+
+## Consequences
+
+Ordinary windows read slightly narrower than the figma baseline (680px floor). Wide columns widen the transcript to at most 920px, and a drag can take it anywhere in `[640px, column − 176px]`, both without touching any derived surface: input card, dock cards, takeover panels, and the back-to-bottom formula follow the axis they already consumed. A known ~4px centering offset between the handle (column-centered) and the content box (centered after scrollbar-gutter reservation) stays well inside the 40px strip. The 680px / 64% / 920px numbers are one declaration in `ConversationRoot.module.css` mirrored by `resolveContentWidth` in the component; retuning them touches nothing else.

+ 35 - 0
.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md

@@ -0,0 +1,35 @@
+# Agent Note:会话正文宽度自适应与拖拽调宽
+
+Status: implemented
+
+[English](2026-08-18-conversation-adaptive-content-width.md) | 中文
+
+## 问题
+
+会话列的共享宽度轴(`--dsh-chat-content-width`)是 figma 定值 748px。在宽显示器上(4000px 屏幕的会话列约 3500px)正文只占列宽不到四分之一,两侧是大片空白边距。所有派生表面——输入卡(W + 32px)、dock 卡片、takeover 面板、StatsLine、回底按钮的 padding 公式——都由这一个变量推导,任何改动都必须保持整列的对齐关系。在自适应默认值之外,用户还要求直接控制:hover 正文两侧边距出现 col-resize 光标,拖任一侧、两侧对称联动。
+
+## 决策
+
+**宽度轴变为"用户覆盖 + 自适应 clamp"。** `ConversationRoot.module.css` 声明 `--dsh-chat-content-width: var(--dsh-chat-user-width, clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px))`。下限 680px——比 figma 的 748px 低一档,因为满宽阅读在各种屏幕上都显宽——更宽的列取列宽的 64%,920px 封顶保证行长可读性(基准字号下约 113 字符)。拖拽偏好存在时整体替换自适应项。
+
+**列宽由 ResizeObserver 发布,不用容器查询。** 组件把根节点的 `offsetWidth` 以 px 发布为 `--dsh-conversation-column-width`(与既有 composer seat 高度 observer 相同的 callback-ref 模式)。拒绝 `container-type: inline-size`:会话子树内有不经 portal 的 `position: fixed` 后代(Tooltip、Menu、JsonTree 复制锚点),尺寸容器会捕获它们的视口定位——与 `.composerHero` 注释记录的 transform 陷阱同类。拒绝变量里的裸 `%`:自定义属性百分比在各消费点按不同包含块解析,破坏输入卡 = W + 32px 不变量;拒绝 `vw`:列不等于视口(侧栏折叠只改列宽)。
+
+**拖拽手柄是正文两侧 40px 宽的条,对称是构造性的。** 每条内边缘位于内容列外 24px、向外延伸 40px,外边缘被钳制在距列缘至少 24px 的安全区(24 + 40 + 24 = 下文每侧 88px 的预算);边距装不下"内偏移 + 热区 + 安全区"时计算宽度为负、热区解析为零。两个手柄写同一个居中宽度——向外拖按指针位移 2 倍变宽——复用 AppFrame DragHandle 的捕获模型(指针捕获 + rAF 节流 + 拖拽起点快照);只有指针确实产生位移的手势才提交存储,因此在被窗口钳制的宽度上按下即松开不会用钳制后的显示值覆盖更宽的已存偏好。hover 提示是跟随指针 Y 的 3px 光带(pointermove 发布 `--dsh-width-handle-pointer-y`):24px 实色核心、两侧各 40px 渐变,用滚动条 hover 色——border token 的透明度在底色上几乎不可见。手柄只在 active 阶段渲染;选举了 composer overlay 的视图(trajectory)隐藏手柄,header 提升到手柄之上(z-index 9)保持可点。
+
+**偏好持久化在 `localStorage`(`dsh.conversation.contentWidth`),钳制不改写。** 列收窄时显示宽度重新钳制到 `[640px, 列宽 − 176px]`(每侧预留 88px 保证手柄永远放得下),但存储的偏好保留——拉宽窗口自动恢复,与 AppFrame 侧栏拖拽同规则。手柄不带重置操作也不带 tooltip;已存储的偏好只会被下一次拖拽替换。
+
+**用户气泡上限跟随宽度轴。** `min(525px, 82%)` 改为 `min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)`(0.702 = 525/748,即 figma 气泡占 figma 列宽的比例),`ui-conversation` MessageItem 与对称的 `ui-goal` 命令气泡同步,气泡随列缩放。748px 缺省值覆盖会话列之外的挂载。
+
+## 备选方案
+
+**调大常量(748 → 约 850)。** 拒绝:所有中等宽度窗口的行长一起变长,伤及多数用户的可读性。
+
+**宽内容出血(代码块、工具卡片突破散文列)。** 阅读工效最佳但涉及 MarkdownText 和所有工具卡片布局;作为可能的二期推迟。
+
+**settings 支持的"宽屏模式"开关。** 为拖拽已覆盖的能力增加持久设置面;不需要。
+
+**输入卡旁 12px 手柄条。** 首版实现,实践中不可用:宽屏上千余像素的边距里只有一条细缝,且 sticky 输入卡遮挡它。改为锚定在光带位置的 40px 条。
+
+## 影响
+
+普通窗口的阅读宽度比 figma 基线略窄(下限 680px)。宽列正文最多放宽到 920px,拖拽可取 `[640px, 列宽 − 176px]` 内任意值,两者都不触碰任何派生表面:输入卡、dock 卡片、takeover 面板和回底公式沿用它们本就消费的宽度轴。手柄(按列居中)与内容盒(按滚动条预留后居中)之间约 4px 的已知偏差完全落在 40px 热区内。680px / 64% / 920px 三个数值在 `ConversationRoot.module.css` 一处声明、由组件内 `resolveContentWidth` 镜像;重调它们不影响其他代码。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.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-18-settings-font-size-control.md
+2026-08-18-settings-font-size-control.md: 34ee7f28f58a0a5a5dc8cd39bc4cecf5e081359f
+2026-08-18-settings-font-size-control.zh.md: 2c2ea714d50ca1f934d78ede6175935338488331

+ 31 - 0
.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md

@@ -0,0 +1,31 @@
+# Agent Note: Settings-backed conversation content font size
+
+Status: implemented
+
+English | [中文](2026-08-18-settings-font-size-control.zh.md)
+
+## Problem
+
+The conversation's body text size was fixed (14px after the 0.875 markdown-ladder rescale). Users asked for a Settings control: a "字号大小" row under General → Appearance with a stepper, range 12–17, default 14, that resizes the transcript body text and the composer input text together.
+
+## Decision
+
+**The theme plugin owns the setting.** `ThemeSettingsSchema` gains `fontSize` (`z.number().step(1).min(12).max(17).default(14)`) beside `preference` in the existing `ui-theme` namespace — one durable section, one settings scope, one adoption path. `ThemeRuntime` carries `fontSize` in `ThemeSnapshot`, exposes `setFontSize(px)` (integer-and-range validated, throws a teaching error), and republishes on `theme/change`. The same plugin registers the FontSizeRow into `settings.general.item` at order 11, directly under the Appearance cubes (order 10).
+
+**Presentation rides the existing snapshot pipeline.** The service never touches the DOM: ui-layout's `ThemePresenter` writes `--dsh-content-font-size` on `body` from each snapshot (and retracts it on dispose), and the Host boot script embeds the durable value in the index response so first paint uses the chosen size — the same pre-plugin path the dark-mode attribute takes, avoiding a font-size flash.
+
+**One CSS delta variable moves the ladder.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Table, small, and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder read `var(--dsh-content-font-size, 14px)` (or `calc(<own default> + var(--dsh-content-font-delta, 0px))` for smaller steps) and `calc(<default line-height> + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (reference summaries and their inline glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), and the flow chrome around them — the shared DisclosureRow header (tool calls, think, commands; row height, title, and leading box all move) with its expanded bodies' `22px + delta` indent keeping content aligned under the shifted title start, ToolRow/bash-row summaries and file links, think text (12px keeping its 2px step under the body), compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel (run/phase headers and expanded member rows), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The 14px fallbacks keep every surface pixel-identical when the variable is absent (tests, storybook-like mounts, remote compositions before adoption).
+
+**The stepper is a pill, not a menu.** The row reuses the selector-pill geometry (h36 r18 module fill) with the value centered in the pill, the up/down arrow column revealed on hover/focus-within and absolutely anchored to the pill's right edge (so revealing never moves the value), and a `px` unit label after the pill. A tertiary description line under the title states the scope — the size only affects conversation content, not the application chrome. Arrows disable at the bounds; the display follows the store mirror, never the click echo — the same store/face pattern as the Appearance row.
+
+## Alternatives considered
+
+**A separate settings namespace or plugin.** Rejected: the font size is an appearance preference with the same persistence, adoption, and remote-browser semantics as the theme preference; a second namespace duplicates the scope machinery for one integer.
+
+**Scaling via a multiplier (`em`/percentage) instead of a px delta.** Rejected: multiplying spreads the 12–17px range disproportionately across the ladder (21px h1 would swing ~18–25.5px) and produces fractional line heights; the fixed px shift keeps every step integer and the hierarchy's px gaps intact.
+
+**Scaling every font token (tables, code, small).** Rejected: those variants are secondary/dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility.
+
+## Consequences
+
+The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at delta 0 every axis consumer is pixel-identical to that rescaled baseline, and surfaces without the variable fall back to the same 14px. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`.

+ 31 - 0
.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:Settings 支撑的会话正文字号
+
+状态:已实现
+
+[English](2026-08-18-settings-font-size-control.md) | 中文
+
+## 问题
+
+会话正文字号是固定的(markdown 阶梯按 0.875 重缩放后为 14px)。用户需要一个设置项:在 General → Appearance 下加一行「字号大小」,用步进器交互,范围 12–17,默认 14,同时调整转录正文与 composer 输入框的文字大小。
+
+## 决策
+
+**主题插件拥有该设置。**`ThemeSettingsSchema` 在既有 `ui-theme` namespace 的 `preference` 旁新增 `fontSize`(`z.number().step(1).min(12).max(17).default(14)`)——一个持久化 section、一个 settings scope、一条采纳路径。`ThemeRuntime` 在 `ThemeSnapshot` 中携带 `fontSize`,暴露 `setFontSize(px)`(校验整数与范围,越界抛教学式错误),并通过 `theme/change` 重新发布。同一插件把 FontSizeRow 注册进 `settings.general.item`,order 11,紧挨外观方块(order 10)之下。
+
+**呈现走既有快照管线。**服务绝不接触 DOM:ui-layout 的 `ThemePresenter` 依据每份快照在 `body` 上写 `--dsh-content-font-size`(dispose 时收回),Host 引导脚本把持久化值嵌入 index 响应,让首帧就使用所选字号——与暗色属性同一条插件前路径,避免字号闪变。
+
+**一个 CSS 增量变量平移阶梯。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。表格、small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外的消费方直接读取 `var(--dsh-content-font-size, 14px)`(较小档位则用 `calc(<自身默认> + var(--dsh-content-font-delta, 0px))`)与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含引用摘要及其行内字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量),以及围绕它们的流内 chrome——共享的 DisclosureRow 头部(工具调用、think、命令;行高、标题与 leading 盒同步移动),其展开内容以 `22px + delta` 缩进跟随平移后的标题起点保持对齐、ToolRow/bash 行的 summary 与文件链接、think 正文(12px,保持比正文小 2px 的层级)、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板(run/phase 头部与展开的成员行)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。14px 回退让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。
+
+**步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。标题下方的三级说明行标明作用范围——字号仅影响会话内容,不影响应用外框。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。
+
+## 已考虑的替代方案
+
+**独立 settings namespace 或独立插件。**否决:字号与主题偏好具有相同的持久化、采纳与远程浏览器语义,属外观偏好;为一个整数复制一套 scope 机制不值得。
+
+**用倍率(`em`/百分比)而非像素增量缩放。**否决:乘法会让 12–17px 的范围在阶梯上不成比例地放大(21px 的 h1 会摆动到约 18–25.5px),并产生小数行高;固定像素平移让每一档都是整数,层级间的像素差保持不变。
+
+**缩放全部字体 token(表格、code、small)。**否决:这些变体按设计就是次级/密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。
+
+## 后果
+
+0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;增量为 0 时所有轴消费方与该重缩放基线逐像素一致,无变量的表面回退到同样的 14px。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.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-20-multiline-question-answer-field.md
-2026-08-20-multiline-question-answer-field.md: 5e411d6728205ce1b72ef8355bbf2493f28873eb
-2026-08-20-multiline-question-answer-field.zh.md: a19178227e813c78b99529fc3e3186e5cfb98e2a
+2026-08-20-multiline-question-answer-field.md: 207a7e5303b6558d7f09fc2684940db4e81ff452
+2026-08-20-multiline-question-answer-field.zh.md: 3c7644675a85e740ad0cb5c299e1f591adbfbac6

+ 1 - 1
.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md

@@ -22,7 +22,7 @@ Enter continues the flow and submits the batch on the last question, Shift+Enter
 
 ## Alternatives considered
 
-**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is already the technique this repository runs in the chat composer.
+**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../../archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is the technique the chat composer ran before its Lexical editor.
 
 **Resize in JS on every keystroke** — set `height: auto`, read `scrollHeight`, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md

@@ -22,7 +22,7 @@ Enter 继续流程并在最后一题提交整批,Shift+Enter 换行,IME 保
 
 ## Alternatives considered
 
-**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它已经是本仓库在聊天输入框中运行的技术。
+**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../../archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它是聊天输入框在改用 Lexical 编辑器之前运行的技术。
 
 **在 JS 中逐次按键重算高度**——置 `height: auto`、读 `scrollHeight`、再写回。否决:每次按键要付两次强制布局,并重新引入镜像所避开的那类陈旧几何缺陷,换来的却是镜像本就具备的能力。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.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-24-folder-reference-pick-vs-drill.md
+2026-08-24-folder-reference-pick-vs-drill.md: de5d4930eb86a2fc961044cab8aecb7bcaf87a9c
+2026-08-24-folder-reference-pick-vs-drill.zh.md: 773e5b2b7cf54b02bc93638ad20bbdc08f34c3d1

+ 31 - 0
.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md

@@ -0,0 +1,31 @@
+# Agent Note: Folder references settle on pick; descent moves to an explicit drill verb
+
+Status: implemented
+
+English | [中文](2026-08-24-folder-reference-pick-vs-drill.zh.md)
+
+## Problem
+
+A directory row in the `@` menu had one verb doing two jobs. Picking it inserted literal `@dir/` text and kept the menu open — the descent path for reaching a file — so a user who wanted the folder *itself* as context never got a settled entity: the token kept its trigger character, stayed editable (typing `123` kept filtering children), and looked nothing like the atomic chip a file pick produces. Field feedback with a competitor screenshot made the expectation concrete: a chosen folder should be as settled as a chosen file.
+
+## Decision
+
+Split the two intents into two verbs on the same row, keyboard-mapped to shell-completion instincts:
+
+- **Settle** (row click / Enter): the directory resolves as an atomic folder chip — the file chip's exact language: folder glyph, `dir/` label, no trigger character, one deletable unit — whose serialized and clipboard form is the canonical `@dir/` mention. Implementation is the `{ insert }` arm the folder path had simply never taken; `appearance: 'folder'` was already supported end to end.
+- **Drill** (Tab / the row's trailing chevron): the previous behavior verbatim — literal editable `@dir/` text, menu open on the children.
+
+The plumbing is one new dimension, not a parallel path: `InputTriggerCandidate.drill?: boolean` advertises the second verb (only `ui-reference` directories set it), `InputTriggerPick.action: 'pick' | 'drill'` reports which one ran, `ArbitrateKey` gains `'tab'`, and the composer keymap registers `KEY_TAB_COMMAND` through the same arbitration helper as the arrows — `'consumed'` prevents default, anything else leaves native focus traversal alone. MenuView renders the chevron only on drill rows (`role="button"` span inside the option, mousedown like the row so composer focus survives, `stopPropagation` so the row's settling pick stays out).
+
+## Alternatives considered
+
+- **Settle on menu close** (auto-fold a literal `@dir/` into a chip when the menu dismisses): rejected — the moment an editable token becomes an entity would be invisible and surprising; hand-typed mentions stay honest text.
+- **Drill on click, settle via a dedicated row button** (the inverse mapping): rejected — settling is the common intent and deserves the primary gesture; descent is the power-user refinement, which matches Tab.
+- **CSS-overpainting the trigger character** on the literal text instead of introducing an entity: rejected earlier for the same reason it failed the folder-glyph fix — a Lexical text node cannot split its trigger character out, and the literal text is not a settled entity anyway.
+
+## Consequences
+
+- A picked folder and a picked file are the same species: atomic, glyph-labeled, no `@`, whole-unit deletion; hand-typed `@dir/` remains a plain-text reference with the glyph prefix.
+- `onPick` implementations that ignore `action` behave exactly as before (`'pick'` is what every pre-existing path reports); the only behavioral change sits in `ui-reference`'s directory arm.
+- Tab is intercepted only while the menu highlights a drill row; everywhere else the browser keeps it, pinned by the keymap-routing spec.
+- Coverage: controller arbitration (drill / plain / pick-action), MenuView chevron routing, `ui-reference` verb split, and a real-browser e2e driving all three gestures (Enter settle, Tab drill, chevron drill) against a real workspace directory.

+ 31 - 0
.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 文件夹引用 pick 即选定;下钻移交给显式 drill 动词
+
+Status: implemented
+
+[English](2026-08-24-folder-reference-pick-vs-drill.md) | 中文
+
+## 问题
+
+`@` 菜单里的目录行用一个动词干两件事。pick 它会插入字面 `@dir/` 文本并保持菜单打开——那是抵达文件的下钻路径——于是想要文件夹*本身*作为上下文的用户永远得不到一个已选定的实体:token 保留触发字符、保持可编辑(继续输入 `123` 会继续筛选子项),与文件 pick 产出的原子 chip 完全不是一个物种。现场反馈附上竞品截图把期望说得很具体:选中的文件夹应当与选中的文件一样"定下来"。
+
+## 决策
+
+把两个意图拆成同一行上的两个动词,键位对齐 shell 补全直觉:
+
+- **选定**(点击行主体 / Enter):目录解析为原子 folder chip——与文件 chip 完全同语言:文件夹图标、`dir/` 标签、无触发字符、整体删除一个单位——序列化与剪贴板形式为规范 `@dir/` mention。实现上就是文件夹路径从未走过的 `{ insert }` 分支;`appearance: 'folder'` 端到端早已支持。
+- **钻取**(Tab / 行尾 chevron):原行为原样保留——字面可编辑的 `@dir/` 文本,菜单对子项保持打开。
+
+管线上是一个新维度而非平行通路:`InputTriggerCandidate.drill?: boolean` 声明第二动词(只有 `ui-reference` 的目录行设置),`InputTriggerPick.action: 'pick' | 'drill'` 报告实际执行的是哪一个,`ArbitrateKey` 增加 `'tab'`,composer keymap 经与方向键相同的仲裁 helper 注册 `KEY_TAB_COMMAND`——`'consumed'` 才 preventDefault,其余情况原生焦点遍历不受影响。MenuView 只在 drill 行渲染 chevron(option 内的 `role="button"` span,与行同用 mousedown 保住 composer 焦点,`stopPropagation` 把行主体的选定 pick 挡在外面)。
+
+## 曾考虑的替代方案
+
+- **菜单关闭时选定**(菜单消失时把字面 `@dir/` 自动固化为 chip):否决——可编辑 token 变实体的时机不可见且令人意外;手敲的 mention 保持诚实文本。
+- **点击钻取、行内专用按钮选定**(反向映射):否决——选定是常见意图,应占据主手势;下钻是进阶细化,与 Tab 匹配。
+- **对字面文本 CSS 覆盖触发字符**而不引入实体:早先在文件夹图标修复中已因同一原因否决——Lexical 文本节点无法拆出触发字符,而且字面文本本来就不是已选定的实体。
+
+## 后果
+
+- pick 出的文件夹与文件是同一物种:原子、带图标标签、无 `@`、整体删除;手敲的 `@dir/` 仍是带图标前缀的纯文本引用。
+- 忽略 `action` 的 `onPick` 实现行为与从前完全一致(既有路径全部报告 `'pick'`);唯一的行为变化在 `ui-reference` 的目录分支。
+- 只有菜单高亮在 drill 行时才拦截 Tab;其余场合浏览器保有该键,由 keymap-routing spec 钉住。
+- 覆盖:controller 仲裁(drill / 普通行 / pick action)、MenuView chevron 路由、`ui-reference` 动词分流,以及在真实工作区目录上驱动全部三个手势(Enter 选定、Tab 钻取、chevron 钻取)的真浏览器 e2e。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.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-25-loaded-turn-chat-navigation.md
+2026-08-25-loaded-turn-chat-navigation.md: 5d9d93b07f7a8c527bf7376bf111c6a709afa4d1
+2026-08-25-loaded-turn-chat-navigation.zh.md: 21dba024710b306848fee9bc4fe1f16913c475b3

Some files were not shown because too many files changed in this diff