Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into fix/input-placeholder-stat-ui-polish

Yif 3 hafta önce
ebeveyn
işleme
78258bf6fd
100 değiştirilmiş dosya ile 1799 ekleme ve 235 silme
  1. 6 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml
  2. 36 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md
  3. 36 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  6. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
  9. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
  11. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml
  12. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md
  13. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md
  14. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml
  15. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md
  16. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md
  17. 3 3
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml
  18. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md
  19. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md
  20. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.i18n.yaml
  21. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md
  22. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md
  23. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml
  24. 31 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
  25. 31 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md
  26. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
  27. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
  28. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
  29. 2 2
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml
  30. 1 1
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
  31. 1 1
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md
  32. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml
  33. 0 0
      .agents/notes/implemented/feature/2026-07-27-web-session-search.md
  34. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md
  35. 2 2
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml
  36. 4 5
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
  37. 4 5
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md
  38. 2 2
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml
  39. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
  40. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md
  41. 2 2
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml
  42. 3 3
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
  43. 3 3
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md
  44. 0 46
      .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
  45. 0 46
      .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md
  46. 6 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  47. 14 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  48. 14 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  49. 6 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml
  50. 29 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md
  51. 29 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md
  52. 6 0
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
  53. 34 0
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
  54. 34 0
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
  55. 6 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml
  56. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
  57. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md
  58. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  59. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  60. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  61. 2 0
      THIRD_PARTY_NOTICES.md
  62. 1 1
      apps/cli/package.json
  63. 3 1
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  64. 7 0
      apps/cli/tests/web-agent-presets.e2e.ts
  65. 1 1
      apps/web/package.json
  66. 7 15
      apps/web/tests/agent-preset-authoring.e2e.ts
  67. 2 1
      apps/web/tests/agent-preset-selection.e2e.ts
  68. 4 1
      apps/web/tests/chat-long-interactions.e2e.ts
  69. 431 0
      apps/web/tests/clickable-links-gallery.e2e.ts
  70. 12 12
      apps/web/tests/command-image-envelope.expected.e2e.ts
  71. 204 0
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  72. 9 0
      apps/web/tests/expected/file-upload-round/draft.expected.md
  73. 10 0
      apps/web/tests/expected/file-upload-round/history.expected.md
  74. 2 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  75. 2 0
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  76. 2 0
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  77. 2 0
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  78. 2 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  79. 2 0
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  80. 2 0
      apps/web/tests/expected/math-rendering/ui.expected.md
  81. 2 0
      apps/web/tests/expected/reference-composer/order.expected.md
  82. 3 0
      apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md
  83. 2 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  84. 2 0
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  85. 2 0
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  86. 2 0
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  87. 2 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  88. 2 0
      apps/web/tests/expected/steer-all/settled.expected.md
  89. 283 0
      apps/web/tests/file-upload-round.e2e.ts
  90. 11 2
      apps/web/tests/goal-command-presentation.e2e.ts
  91. 12 16
      apps/web/tests/image-display.expected.e2e.ts
  92. 16 3
      apps/web/tests/message-actions.e2e.ts
  93. 4 6
      apps/web/tests/navigation-panes.e2e.ts
  94. 46 12
      apps/web/tests/scaffold.ts
  95. 11 2
      apps/web/tests/skill-invocation-policy.e2e.ts
  96. 4 0
      apps/web/tests/skill-user-invoke.e2e.ts
  97. 2 0
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  98. 2 2
      apps/web/tests/submission-echo.e2e.ts
  99. 6 1
      apps/web/tests/trajectory-virtualization.e2e.ts
  100. 2 0
      apps/web/tsconfig.json

+ 6 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.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-09-03-session-search-result-reveal.md
+2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0
+2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0

+ 36 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md

@@ -0,0 +1,36 @@
+# Agent Note: Session search result reveal
+
+Status: implemented
+Archived: 2026-09-03
+
+English | [中文](2026-09-03-session-search-result-reveal.zh.md)
+
+## Problem
+
+Selecting a Session search result opened its conversation while leaving the sidebar in the filtered search view. The user could not see where the Session belonged in the normal Workspace hierarchy. Clearing search alone was insufficient because the owning Workspace could be closed, the Session could be hidden beyond the five-row fold, and either grouped or flat navigation could place the row outside the scrollport.
+
+## Decision
+
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override.
+
+The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id.
+
+## Alternatives considered
+
+**Preserve the query after opening the Session.** This keeps the discovery context but leaves the user in the temporary result list and does not identify the Session's normal location.
+
+**Clear search without opening or unfolding the owning group.** The conversation would open while its selected row could remain hidden, reproducing the missing-location problem in a different sidebar state.
+
+**Persist an expanded-all preference for the group.** One navigation would permanently replace the bounded five-row presentation. The reveal instead expands the remainder only for the current tree mount.
+
+**Scroll from the browser parent.** The parent cannot complete the operation before a folded target row mounts. The row that owns the DOM element performs and acknowledges the scroll.
+
+## Consequences
+
+Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment.
+
+The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts.
+
+## Testing
+
+UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition.

+ 36 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md

@@ -0,0 +1,36 @@
+# Agent Note: Session 搜索结果显露
+
+Status: implemented
+Archived: 2026-09-03
+
+[English](2026-09-03-session-search-result-reveal.md) | 中文
+
+## 问题
+
+选择 Session 搜索结果会打开其对话,但侧边栏仍停留在筛选后的搜索视图。用户无法在常规 Workspace 层级中看到该 Session 的所属位置。仅清空搜索还不够,因为所属 Workspace 可能处于关闭状态,Session 可能隐藏在五行折叠之后,而分组或单列表导航都可能把该行放在滚动区域之外。
+
+## 决策
+
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。
+
+常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。
+
+## 考虑过的替代方案
+
+**打开 Session 后保留查询。** 这会保留发现上下文,却仍让用户停留在临时结果列表中,无法识别 Session 在常规层级中的位置。
+
+**只清空搜索,不打开或展开所属分组。** 对话会打开,但选中行仍可能隐藏,只是把缺失位置的问题带到另一种侧边栏状态。
+
+**为分组持久保存全部展开偏好。** 一次导航会永久取代有界的五行呈现。本次显露只在当前树挂载期间展开其余条目。
+
+**由浏览器父组件执行滚动。** 折叠的目标行挂载前,父组件无法完成操作。持有 DOM 元素的行负责滚动并确认完成。
+
+## 后果
+
+选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。
+
+目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。
+
+## 测试
+
+UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。

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

@@ -145,6 +145,9 @@
     "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",
+    "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
+    "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
+    "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
     "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
     "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
     "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",

+ 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: 200761cc9e648eea80bdae9d7b363246c816e5d1
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c
+2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0

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

@@ -67,8 +67,8 @@ 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 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).
+- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) 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 `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
 - 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

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

@@ -67,8 +67,8 @@ 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` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
-- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
+- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
+- 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。
 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
 
 ### 每会话供数贡献与键盘私面

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 425975944f7b23575c4f716dee8754dc6b3ab54c
-2026-08-09-client-conversation-node-assembly.zh.md: 96439fa951cdc1d6abe397ee37661291c0f954a7
+2026-08-09-client-conversation-node-assembly.md: 50e11b04d6461e99cdddb8e5e2d94bf864228841
+2026-08-09-client-conversation-node-assembly.zh.md: 7be1cd1b7f356caf51b8003821369771b0a6e269

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -278,7 +278,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
 |---|---|---|---|
 | Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context |
 | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
-| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
+| Request Prompt | Immediate by default | One non-empty `system-prompt` for the initial request, each explicit series, or a real system change | A step's first header anchors before its request messages; prepend can hide a conservatively rendered resume after its preceding header proves the system unchanged |
 | Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation |
 | Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
 | Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
@@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
 
 Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it.
 
-Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history.
+Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)).
 
 Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
 |---|---|---|---|
 | Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context |
 | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 |
-| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
+| Request Prompt | 默认 immediate | 初始请求、每个显式序列或真实 system 变化各生成一个非空 `system-prompt` | Step 首条 header 锚定在请求消息之前;prepend 补入前序 header 并证明 system 未变后,可隐藏此前保守渲染的 resume |
 | Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 |
 | Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key |
 | Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
@@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
 
 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。
 
-Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。
+Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。
 
 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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-same-basename-workspace-adoption.md
-2026-07-31-same-basename-workspace-adoption.md: a634972448d8a3fea3d08c5713602cec0584a44e
-2026-07-31-same-basename-workspace-adoption.zh.md: 82be4aee3b3da2e9fb02783f1cd9bf470802cae0
+2026-07-31-same-basename-workspace-adoption.md: d7c4f7a9d67d760dccbdc536b4894292a171ee3e
+2026-07-31-same-basename-workspace-adoption.zh.md: 968863a24c6e7c6237d814f20e9055fd029178a4

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md

@@ -6,11 +6,11 @@ English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md)
 
 ## Problem
 
-A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
+A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its directory-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
 
 ## Decision
 
-`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it.
+`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record derives its title from the final path segment, falling back to the root spelling when that segment is empty. The [fully qualified Workspace path decision](2026-09-03-fully-qualified-workspace-paths.md) owns path admission and the title fallback.
 
 The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation.
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md

@@ -6,11 +6,11 @@ Status: implemented
 
 ## 问题
 
-Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
+Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按目录名称派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
 
 ## 决策
 
-`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。
+`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录从最终路径段派生标题,该路径段为空时回退到根路径拼写。[Workspace 完全限定路径决策](2026-09-03-fully-qualified-workspace-paths.zh.md)负责规定路径准入和标题回退。
 
 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.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-09-03-fully-qualified-workspace-paths.md
+2026-09-03-fully-qualified-workspace-paths.md: 0bc6215ec5f6485856153b5a5c59d0ed68f485bb
+2026-09-03-fully-qualified-workspace-paths.zh.md: b8e67f9d34fc8ccb7759c95017dd3ab4fde56113

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md

@@ -0,0 +1,35 @@
+# Agent Note: Fully qualified Workspace paths
+
+Status: implemented
+
+English | [中文](2026-09-03-fully-qualified-workspace-paths.zh.md)
+
+## Problem
+
+Workspace path identity must name one directory independently of process state. POSIX relative paths, Windows drive-relative paths such as `C:work`, and Windows root-relative paths such as `\\work` can resolve against the Host cwd or the current directory retained for a drive. Passing those spellings to `realpath` can therefore register a different directory when host state changes. Filesystem roots also have an empty basename, which can create an empty default Workspace title.
+
+Windows drive roots need separate handling in browser-safe relative-path joins. Removing the trailing separator from `C:\\` produces `C:`, which changes an absolute path into a drive-relative path.
+
+## Decision
+
+`WorkspaceRegistry.create()` and `resolveByPath()` reject paths that are not fully qualified before calling `realpath`. POSIX requires an absolute path. Windows requires `win32.isAbsolute(path)` plus a parsed root that is neither `\\` nor `/`; this accepts drive-qualified and UNC paths while rejecting current-drive-root and drive-relative spellings without maintaining a second path grammar.
+
+Canonical paths remain the registry identity. A default title uses the final path segment, or `node:path`'s parsed root when that segment is empty. This refines the display rule owned by [same-basename Workspace adoption](2026-07-31-same-basename-workspace-adoption.md) without making titles unique.
+
+The browser-safe `resolveWorkspacePath()` removes trailing separators only after choosing the separator from the Workspace spelling. Backslash drive and UNC paths keep `\\`; forward-slash drive paths keep `/`; joining a drive root always retains the separator after the colon.
+
+## Alternatives considered
+
+**Resolve relative paths against the Host cwd.** Rejected because Workspace identity would depend on process state that callers do not supply and remote clients cannot observe.
+
+**Resolve relative paths against another stored Workspace.** Rejected because create and lookup requests do not identify such an anchor, and guessing one would make the same path spelling address different records.
+
+**Maintain a regular expression for drive and UNC syntax.** Rejected because `node:path.win32` already parses roots and absolute paths; a second grammar can diverge on separator variants and UNC roots.
+
+**Use an empty title for filesystem roots.** Rejected because the title is the primary Workspace label. The root spelling is short, stable, and already distinguishes drive and UNC roots.
+
+## Consequences
+
+Callers must submit fully qualified Workspace paths. Invalid path spellings fail before filesystem access, while nonexistent fully qualified paths still return the original filesystem error. Windows drive and UNC roots remain valid identities and have non-empty default titles; an UNC share root uses the share name as its final segment.
+
+Workspace-relative joins preserve the separator style already present in the Workspace root. Unit tests cover POSIX roots, drive roots, UNC roots, rejected drive-relative and current-drive-root paths, and both Windows separator styles.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: Workspace 完全限定路径
+
+Status: implemented
+
+[English](2026-09-03-fully-qualified-workspace-paths.md) | 中文
+
+## 问题
+
+Workspace 路径身份必须在不依赖进程状态的情况下指向唯一目录。POSIX 相对路径、`C:work` 等 Windows 驱动器相对路径,以及 `\\work` 等 Windows 当前驱动器根相对路径,可能依据宿主 cwd 或驱动器保留的当前目录完成解析。把这些拼写传给 `realpath`,会在宿主状态变化时注册不同目录。文件系统根目录的 basename 也为空,可能产生空的默认 Workspace 标题。
+
+浏览器安全的相对路径连接还需要单独处理 Windows 驱动器根。移除 `C:\\` 的尾部分隔符会得到 `C:`,从而把绝对路径变成驱动器相对路径。
+
+## 决策
+
+`WorkspaceRegistry.create()` 和 `resolveByPath()` 会在调用 `realpath` 前拒绝不是完全限定形式的路径。POSIX 要求绝对路径。Windows 要求 `win32.isAbsolute(path)`,且解析出的根既不是 `\\` 也不是 `/`;该规则接受驱动器限定路径与 UNC 路径,同时无需维护第二套路径语法即可拒绝当前驱动器根拼写和驱动器相对拼写。
+
+规范路径继续作为注册表身份。默认标题使用最终路径段;该路径段为空时,则使用 `node:path` 解析出的根。该规则细化了[接纳 basename 相同 Workspace](2026-07-31-same-basename-workspace-adoption.zh.md)拥有的显示规则,但不会要求标题唯一。
+
+浏览器安全的 `resolveWorkspacePath()` 会先根据 Workspace 拼写选择分隔符,再移除尾部分隔符。使用反斜杠的驱动器与 UNC 路径保留 `\\`,使用正斜杠的驱动器路径保留 `/`,与驱动器根连接时始终保留冒号后的分隔符。
+
+## 考虑过的替代方案
+
+**依据宿主 cwd 解析相对路径。** 不予采纳,因为 Workspace 身份将依赖调用方未提供、远程客户端无法观察的进程状态。
+
+**依据另一个已存储 Workspace 解析相对路径。** 不予采纳,因为 create 和 lookup 请求没有指定这种锚点,猜测锚点会让同一路径拼写指向不同记录。
+
+**维护用于驱动器和 UNC 语法的正则表达式。** 不予采纳,因为 `node:path.win32` 已经解析根和绝对路径;第二套语法可能在分隔符变体和 UNC 根上发生偏差。
+
+**为文件系统根目录使用空标题。** 不予采纳,因为标题是 Workspace 的主要标签。根路径拼写简短、稳定,并且已经可以区分驱动器与 UNC 根。
+
+## 后果
+
+调用方必须提交完全限定的 Workspace 路径。无效路径拼写会在文件系统访问前失败,而不存在的完全限定路径仍返回原始文件系统错误。Windows 驱动器根与 UNC 根继续作为有效身份,并具有非空默认标题;UNC share 根使用 share 名称作为最终路径段。
+
+Workspace 相对路径连接会保留 Workspace 根中已有的分隔符风格。单元测试覆盖 POSIX 根、驱动器根、UNC 根、被拒绝的驱动器相对路径与当前驱动器根路径,以及两种 Windows 分隔符风格。

+ 3 - 3
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml → .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.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/feature/2026-08-17-command-image-attachment-envelope.md
-2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9
-2026-08-17-command-image-attachment-envelope.zh.md: 9de197d06692523b7b5bca4f7aac36ce9f44b105
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md
+2026-09-03-hidden-windows-subprocess-windows.md: 97af84ddc0a51483fa0f1147caee0ea0374c240d
+2026-09-03-hidden-windows-subprocess-windows.zh.md: d8f986278e974342cd24abd6fd3192165762c1f9

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md

@@ -0,0 +1,29 @@
+# Agent Note: Suppressing Windows subprocess windows
+
+Status: implemented
+
+English | [中文](2026-09-03-hidden-windows-subprocess-windows.zh.md)
+
+## Problem
+
+The local subprocess provider can run under a GUI or service host with no visible console. Windows creates a new visible window for a child process when the host does not supply one, so an ordinary command or a `taskkill` helper can flash and take focus even though the harness has no user-facing terminal for that process.
+
+## Decision
+
+The provider sets `windowsHide: true` on every non-terminal `spawn` and on both synchronous `taskkill` call sites. The main child uses this option only for the Windows execution path; `taskkill` is itself Windows-only. Terminal processes retain the visibility and console behavior owned by the PTY implementation.
+
+The option hides console windows and GUI windows that honor the Windows process startup visibility setting. Callers do not choose this behavior because the local provider owns whether its background process management creates host windows.
+
+## Alternatives considered
+
+**Hide only the main child.** Rejected because cancellation, timeout escalation, terminal teardown, and host-exit cleanup can still launch `taskkill` and flash a console window.
+
+**Expose a caller option.** Rejected because consumers cannot reliably know whether the local host has a console, and inconsistent choices would reintroduce focus-stealing process-management windows.
+
+**Hide only console programs.** Rejected because Node exposes one Windows startup option rather than a reliable pre-spawn executable classification, and probing the target would add platform-specific races without preserving a useful product behavior.
+
+## Consequences
+
+Background subprocess operations do not create visible Windows child or `taskkill` windows. A directly launched GUI program that honors the startup visibility setting also starts hidden; consumers that need an interactive visible application must use a capability that owns that user interaction instead of the background subprocess provider.
+
+Unit tests inject the process launchers and pin `windowsHide` for the main child and both `taskkill` paths without creating host-global windows or terminating real processes.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 隐藏 Windows 子进程窗口
+
+Status: implemented
+
+[English](2026-09-03-hidden-windows-subprocess-windows.md) | 中文
+
+## 问题
+
+本地 subprocess provider 可以在没有可见控制台的 GUI 或服务宿主中运行。宿主未提供控制台时,Windows 会为子进程创建新的可见窗口,因此普通命令或 `taskkill` 辅助进程可能闪现并抢占焦点,即使 harness 并未为该进程提供面向用户的 terminal。
+
+## 决策
+
+provider 对每次非 terminal `spawn` 以及两处同步 `taskkill` 调用都设置 `windowsHide: true`。主子进程只在 Windows 执行路径使用此选项;`taskkill` 本身只用于 Windows。terminal 进程继续采用 PTY 实现拥有的可见性与控制台行为。
+
+该选项会隐藏控制台窗口,以及遵循 Windows 进程启动可见性设置的 GUI 窗口。调用方不能选择此行为,因为本地 provider 负责决定其后台进程管理是否创建宿主窗口。
+
+## 考虑过的替代方案
+
+**只隐藏主子进程。** 不予采纳,因为取消、超时升级、terminal 拆卸与宿主退出清理仍可能启动 `taskkill` 并闪现控制台窗口。
+
+**暴露调用方选项。** 不予采纳,因为消费方无法可靠判断本地宿主是否拥有控制台,不一致的选择会重新引入抢占焦点的进程管理窗口。
+
+**只隐藏控制台程序。** 不予采纳,因为 Node 只暴露一个 Windows 启动选项,无法在 spawn 前可靠区分可执行文件类型;探测目标还会增加平台特定竞态,却不能保留有用的产品行为。
+
+## 后果
+
+后台 subprocess 操作不会创建可见的 Windows 子进程或 `taskkill` 窗口。直接启动且遵循启动可见性设置的 GUI 程序也会以隐藏方式运行;需要交互式可见应用的消费方必须使用拥有该用户交互的能力,而不是后台 subprocess provider。
+
+单元测试注入进程 launcher,固定主子进程和两条 `taskkill` 路径的 `windowsHide`,且不会创建宿主全局窗口或终止真实进程。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.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-09-03-normalized-unread-fs-tool-diagnostic.md
+2026-09-03-normalized-unread-fs-tool-diagnostic.md: e7ae930ee6b5577d23a58caaeb096929255305a7
+2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md: 907cd09ea28c79d0b26cb4791198ef055774d593

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md

@@ -0,0 +1,29 @@
+# Agent Note: Normalized unread filesystem tool diagnostic
+
+Status: implemented
+
+English | [中文](2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md)
+
+## Problem
+
+The `dsh-tool-fs` write and edit operations can receive `FS_NOT_OBSERVED` from either the observation policy or a filesystem provider. Those sources describe the same requirement with operation-specific messages, so identical recovery conditions reach the model with different wording. Provider text can also expose whether the rejected operation would overwrite an existing target, although the model only needs to read the target and retry.
+
+## Decision
+
+`remediateFsError(error, displayPath)` replaces every `FS_NOT_OBSERVED` message at the `dsh-tool-fs` model boundary with `cannot modify "<path>": file has not been read — read the file, then retry`. The wrapper preserves the structured error code and chains the source error as `cause`, so machine routing and diagnostics can still inspect the original failure.
+
+`FS_STALE_VERSION` retains the appended re-read remedy owned by the [guarded-mutation remedy note](../feature/2026-08-03-fs-tool-error-remedy.md). Filesystem providers and policies keep their operation-specific messages because other consumers do not share the tool's model-facing presentation.
+
+## Alternatives considered
+
+**Append the same recovery suffix to each source message.** Rejected because the model would still receive different reasons for one required action, including provider-specific target-existence detail that does not change recovery.
+
+**Normalize the provider and policy messages at their source.** Rejected because those components own machine-oriented errors used by consumers other than `dsh-tool-fs`; only the tool owns this model-visible wording.
+
+**Introduce another error code for the normalized result.** Rejected because the underlying condition and recovery routing remain `FS_NOT_OBSERVED`; changing the code would discard useful compatibility for machine consumers.
+
+## Consequences
+
+Write and edit expose one stable unread-target diagnostic regardless of whether policy or provider rejects the mutation. The model gives up source-specific wording and the provider's target-existence hint in exchange for one actionable recovery instruction. The original message remains available through `cause`.
+
+Unit and integration tests pin both source paths, code preservation, cause chaining, and the exact model-visible text. The `fs-policy-reject` recorded session carries the same diagnostic for replay.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 统一未读取文件系统工具诊断
+
+Status: implemented
+
+[English](2026-09-03-normalized-unread-fs-tool-diagnostic.md) | 中文
+
+## 问题
+
+`dsh-tool-fs` 的 write 和 edit 操作可能从观测策略或文件系统提供方收到 `FS_NOT_OBSERVED`。这些来源用操作特定消息描述相同要求,因此相同恢复条件会以不同措辞到达模型。提供方文本还可能暴露被拒绝的操作是否会覆盖既有目标,但模型只需读取目标后重试。
+
+## 决策
+
+`remediateFsError(error, displayPath)` 在 `dsh-tool-fs` 模型边界把每条 `FS_NOT_OBSERVED` 消息替换为 `cannot modify "<path>": file has not been read — read the file, then retry`。包装层保留结构化错误码,并把来源错误链为 `cause`,因此机器路由与诊断仍能检查原始故障。
+
+`FS_STALE_VERSION` 继续使用[受防护变更恢复指令记录](../feature/2026-08-03-fs-tool-error-remedy.zh.md)拥有的追加式重新读取指令。文件系统提供方与策略保留其操作特定消息,因为其他消费方并不共享该工具面向模型的呈现。
+
+## 考虑过的替代方案
+
+**为每条来源消息追加相同恢复后缀。** 不予采纳,因为模型仍会为同一项必要操作收到不同原因,其中包含不会改变恢复方式的提供方目标存在性细节。
+
+**在提供方与策略源头统一消息。** 不予采纳,因为这些组件拥有供 `dsh-tool-fs` 之外消费方使用的面向机器错误;只有该工具拥有这段模型可见措辞。
+
+**为统一后的结果引入另一个错误码。** 不予采纳,因为底层条件与恢复路由仍是 `FS_NOT_OBSERVED`;改变错误码会丢失机器消费方需要的兼容性。
+
+## 后果
+
+无论变更由策略还是提供方拒绝,write 和 edit 都会给出同一条稳定的未读取目标诊断。模型放弃来源特定措辞和提供方的目标存在性提示,以换取一条统一且可执行的恢复指令。原始消息仍可通过 `cause` 获取。
+
+单元与集成测试固定两条来源路径、错误码保留、cause 链和模型可见文本全文。`fs-policy-reject` 录制会话携带同一条诊断用于重放。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.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-09-03-resume-headers-do-not-repeat-system-prompts.md
+2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d
+2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md

@@ -0,0 +1,31 @@
+# Agent Note: Resume headers do not repeat system prompts
+
+Status: implemented
+
+English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md)
+
+## Problem
+
+Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation.
+
+## Decision
+
+The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition.
+
+A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible.
+
+Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction.
+
+## Alternatives considered
+
+**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain.
+
+**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison.
+
+**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory.
+
+## Consequences
+
+Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node.
+
+The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control.

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Resume header 不重复系统提示词
+
+Status: implemented
+
+[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文
+
+## 问题
+
+fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。
+
+## 决策
+
+持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。
+
+部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。
+
+Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。
+
+## 考虑过的替代方案
+
+**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。
+
+**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。
+
+**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。
+
+## 后果
+
+继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。
+
+单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b
-2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99
+2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf
+2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md

@@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events,
 
 ## Decision
 
-`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
+`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal.
 
 `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism).
 
@@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
 
 **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
 
-**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
+**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
 
 **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md

@@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
 
 ## 决策
 
-`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
+`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。
 
 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。
 
@@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
 
 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。*
 
-**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
+**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
 
 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
-2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f
-2026-07-27-trajectory-inspection-ledger.zh.md: 6a38147a075480882db5c39a0810404654c6c849
+2026-07-27-trajectory-inspection-ledger.md: 75f8480d3e52c6cc1f2eefd7ef9d5a2c03b40dee
+2026-07-27-trajectory-inspection-ledger.zh.md: 82af4904d90a6fbfc811d176dd4b859b26d4774c

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md

@@ -23,7 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
 - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
 - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes.
 - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
-- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
+- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. The virtualizer's stable-key `followOnAppend` behavior owns structural appends while the user remains at the bottom; the component writes the tail position itself only for a non-virtual ledger. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
 - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.
 - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger.
 - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md

@@ -23,7 +23,7 @@ Status: implemented
 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早的 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失。
 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
-- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
+- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。用户保持在底部时,虚拟化器基于稳定键的 `followOnAppend` 行为负责结构性追加;组件只对非虚拟记录表自行写入末尾位置。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。
 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。
 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md
-2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2
-2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0
+2026-07-27-web-session-search.md: d7a979a278113f9dcc170ec12f9d0fb37e1d55b7
+2026-07-27-web-session-search.zh.md: ba125ba884a1c93f3d2ac23b7f750c6ca216ed5f

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
.agents/notes/implemented/feature/2026-07-27-web-session-search.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md

@@ -14,7 +14,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
 
 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。
 
-[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并在常规浏览器中显露选中行;不会跳转至确切事件。
 
 结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。
 
@@ -41,4 +41,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
 
 ## 测试
 
-宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
+宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证搜索退出且选中的 Session 在常规浏览器中可见。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
-2026-08-03-fs-tool-error-remedy.md: 66d47b750afdd43c4e2b830e73e21a3341f26c4b
-2026-08-03-fs-tool-error-remedy.zh.md: efa3062f65fe458cd44fadef074583c1b15a3365
+2026-08-03-fs-tool-error-remedy.md: ae72e04b5b662cba79fe52538ff190a25f74eceb
+2026-08-03-fs-tool-error-remedy.zh.md: 3c65ca06583c47c93081d74606f489c4bcdc81f2

+ 4 - 5
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md

@@ -1,4 +1,4 @@
-# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
+# Agent Note: Stale-version errors append the recovery instruction at the model boundary
 
 Status: implemented
 
@@ -10,14 +10,13 @@ Guarded `write` and `edit` failures reach the model with messages that state the
 
 ## Decision
 
-`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched:
+`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to stale-version failures and passes unrelated errors through untouched. The [normalized unread-mutation diagnostic](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md) supersedes this note's original `FS_NOT_OBSERVED` text treatment.
 
 - `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`.
-- `FS_NOT_OBSERVED` gains `— read the file, then retry`.
 
 The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged.
 
-In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording.
+In `edit.ts` the `fs/edit-intent` waterfall sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal and the provider refusal both pass through the model-facing wrapper.
 
 ## Alternatives considered
 
@@ -27,6 +26,6 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th
 
 ## Consequences
 
-Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-observation-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
+The `FS_STALE_VERSION` model-visible text includes its appended remedy. Unit tests cover its text, code preservation, cause chaining, and passthrough of unrelated values; assembled tool paths assert that the remedy reaches the model.
 
 The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator.

+ 4 - 5
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 受防护变更错误在模型边界追加恢复指令
+# Agent Note: 陈旧版本错误在模型边界追加恢复指令
 
 Status: implemented
 
@@ -10,14 +10,13 @@ Status: implemented
 
 ## 决策
 
-`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传:
+`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为陈旧版本错误追加恢复指令,其余无关错误原样透传。[未读取变更的统一诊断](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md)取代本记录最初对 `FS_NOT_OBSERVED` 文本的处理方式。
 
 - `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。
-- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。
 
 结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。
 
-在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent slot 抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。
+在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)与提供方变更位于同一个 `try` 内,因此策略插件的 `FS_NOT_OBSERVED` 拒绝和提供方拒绝都会经过面向模型的包装层。
 
 ## 考虑过的替代方案
 
@@ -27,6 +26,6 @@ Status: implemented
 
 ## 后果
 
-两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-observation-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
+`FS_STALE_VERSION` 的模型可见文本包含追加的恢复指令。单元测试覆盖该文本、错误码保留、cause 链和无关值透传;组装后的工具路径断言恢复指令到达模型。
 
 [文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`,不再附加陈旧恢复指令;write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
-2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238
-2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17
+2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76
+2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md

@@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md)
 
 ## Problem
 
-The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
+The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
 
 ## Decision
 
-The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
+The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
 
-The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token.
+The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token.
 
 ## Alternatives considered
 
@@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and
 
 **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product.
 
+**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason.
+
 ## Consequences
 
-Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged.
+Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged.

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## 问题
 
-Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
+Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
 
 ## 决策
 
-`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
+`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
 
-评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
+评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
 
 ## 考虑过的替代方案
 
@@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字
 
 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。
 
+**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。
+
 ## 后果
 
-用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。
+用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
-2026-08-11-workspace-sidebar-order-and-folding.md: 4582631da79c946a2d3a713bc0d3a085ebed4cd0
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: 6dfd443599b6c6696bbe12c19ebcde411d86a517
+2026-08-11-workspace-sidebar-order-and-folding.md: a8026936905d42ccf5f30a02684310324d8ee359
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: 83a66c9b4a5f8dd34b56c6d8a8af2d18f77e983b

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

@@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio
 
 ### Session folding and view order
 
-Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
+Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result waits for the current Workspace stream's complete Host baseline before opening its owning group, and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
 
 The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
 
@@ -51,9 +51,9 @@ Search is a header action while collapsed and expands across the title and trail
 - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account.
 - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position.
 - A newly selected blank New Session row enters grouped and flat orders first once. Grouped folding renders it in addition to five non-blank rows until its first prompt, then applies the ordinary quota.
-- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture; the selected blank New Session may add one provisional row. Closing the Workspace resets only the transient gesture.
+- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture or search-result navigation; the selected blank New Session may add one provisional row. Closing the Workspace resets transient remainder expansion.
 - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md).
 
 ## Testing
 
-Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.
+Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, initial and reconnecting Workspace-baseline waits, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.

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

@@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 ### Session 折叠与视图顺序
 
-每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
+每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等待当前 Workspace stream 取得完整 Host 基线后再打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
 
 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
 
@@ -51,9 +51,9 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。
 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。
 - 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次。分组折叠会在五条非空白行之外额外渲染该行,直至首条提示词落地后恢复普通配额。
-- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组只重置这项临时手势。
+- 未执行明确的**展开其余**手势或搜索结果导航时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组会重置临时的其余条目展开状态。
 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.zh.md)确立的手动顺序含义。
 
 ## 测试
 
-领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。
+领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、初始与重连 Workspace 基线等待、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。

+ 0 - 46
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md

@@ -1,46 +0,0 @@
-# Agent Note: Command image-attachment envelope
-
-Status: implemented
-
-English | [中文](2026-08-17-command-image-attachment-envelope.zh.md)
-
-## Problem
-
-The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest.
-
-Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork.
-
-## Decision
-
-The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly.
-
-**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot.
-
-**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics.
-
-**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
-
-**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan <message>` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit.
-
-**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
-
-## Testing
-
-Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes.
-
-## Alternatives considered
-
-- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all.
-- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule.
-- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility.
-- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors.
-- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself.
-- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`.
-
-## Consequences
-
-- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike.
-- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly.
-- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt.
-- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary.
-- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written".

+ 0 - 46
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md

@@ -1,46 +0,0 @@
-# Agent Note: Command image-attachment envelope
-
-Status: implemented
-
-[English](2026-08-17-command-image-attachment-envelope.md) | 中文
-
-## Problem
-
-Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。
-
-合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.zh.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。
-
-## Decision
-
-提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。
-
-**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。
-
-**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。
-
-**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
-
-**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan <message>` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。
-
-**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
-
-## Testing
-
-注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。
-
-## Alternatives considered
-
-- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。
-- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。
-- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。
-- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。
-- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。
-- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。
-
-## Consequences
-
-- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。
-- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。
-- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。
-- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。
-- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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-26-generic-file-upload.md
+2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402
+2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071

Dosya farkı çok büyük olduğundan ihmal edildi
+ 14 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 14 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md


+ 6 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.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-31-cross-process-session-write-lease.md
+2026-08-31-cross-process-session-write-lease.md: 174c5152ea62e01e30ade9a68b6786638acb8ada
+2026-08-31-cross-process-session-write-lease.zh.md: e4246f12f7ed8d8b304ca7f7514117f03f32267b

+ 29 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md

@@ -0,0 +1,29 @@
+# Agent Note: cross-process session write lease
+
+Status: implemented
+
+English | [中文](2026-08-31-cross-process-session-write-lease.zh.md)
+
+## Problem
+
+The JSONL backend's write-handle claim excluded a second writer only inside one backend instance. Two processes — two CLI sessions, or a host beside an SDK runtime — could write-open the same session and interleave appends into one log file, tearing compressed frames and seq contiguity. The seam needed durable cross-process write ownership whose arbiter lives outside every writer process, because no writer outlives every failure mode.
+
+## Decision
+
+`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the pinned native dependency `fs-ext`, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs fs-ext to immediate success: it is single-process, so the in-process write claim already excludes every writer.
+
+## Alternatives considered
+
+**TTL record with renewal and claim-by-rename (implemented first, replaced in review)** — a JSON record beside the log carrying an owner token and expiry, renewed on an interval, taken over by atomic rename after expiry. It survives every filesystem but is a distributed algorithm in miniature: renewal timers, loss detection, takeover claiming with re-judgment and give-back — and its residual multi-actor races still allowed bounded dual-writer overlap (one renewal interval). Kernel arbitration deletes the whole family plus the machinery, at the cost of a native build dependency and the wedged-holder semantics above.
+
+**`proper-lockfile`** — the npm ecosystem's staleness-plus-touch implementation of the same TTL model. It retains the delete-then-recreate takeover race, detects compromise by mtime and inode (weaker than an owner token), and has had no release since 2021.
+
+**fs-ext's own Windows face (`LockFileEx` byte-range locks)** — rejected after CI proof: Windows byte-range locks are mandatory, so any reader touching the locked file hard-fails (ripgrep died with os error 33 walking a session directory).
+
+**Windows exclusive-open sharing mode (`CreateFileW` denying `FILE_SHARE_WRITE`)** — leaves readers untouched but pins the lock file's name and directory while held: CI showed dozens of suites failing their temp-root cleanup with EBUSY because a still-open handle blocks recursive removal, and users deleting a session directory would hit the same wall. The named semaphore keeps kernel arbitration with zero filesystem footprint.
+
+**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — avoids the node-gyp install-time build, but means owning both platform lock implementations plus their error mapping; `fs-ext` ships the POSIX code maintained and pinned, and the Windows side reuses the koffi bindings `win32.ts` already owns.
+
+## Consequences
+
+Cross-process exclusion costs a node-gyp-compiled native dependency (`fs-ext`, allow-listed in `pnpm-workspace.yaml` `allowBuilds`), one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit.

+ 29 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 跨进程会话写租约
+
+Status: implemented
+
+[English](2026-08-31-cross-process-session-write-lease.md) | 中文
+
+## Problem
+
+JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写入方。两个进程——两个 CLI 会话,或宿主与 SDK 运行时并存——可以对同一会话执行写打开,把追加交错写进同一个日志文件,撕坏压缩帧与 seq 连续性。该 seam 需要一份仲裁者位于所有写入进程之外的持久跨进程写所有权,因为没有任何写入方能活过所有故障模式。
+
+## Decision
+
+`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由固定版本的原生依赖 `fs-ext` 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 fs-ext 存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。
+
+## Alternatives considered
+
+**TTL 记录加续约与 rename 认领(最初实现,review 中被替换)** —— 日志旁的 JSON 记录携带 owner 令牌与过期时间,按间隔续约,过期后以原子 rename 接管。它在所有文件系统上都能活,但本质是一个微缩的分布式算法:续约定时器、丢失检测、带复核与归还的接管认领——而其残余的多方竞态仍允许有界的双写重叠(一个续约间隔)。内核仲裁删除了整族竞态及其全部机制,代价是一个原生构建依赖和上述卡死持有者语义。
+
+**`proper-lockfile`** —— npm 生态对同一 TTL 模型的"过期判定加 touch"实现。它保留"先删后建"的接管竞态,用 mtime 加 inode 检测失主(弱于 owner 令牌),且自 2021 年起再无发布。
+
+**fs-ext 自带的 Windows 实现(`LockFileEx` 字节区间锁)** —— 被 CI 实证否决:Windows 的字节区间锁是强制锁,任何读到被锁文件的进程都会硬失败(ripgrep 遍历会话目录时以 os error 33 崩掉)。
+
+**Windows 共享模式独占打开(`CreateFileW` 拒绝 `FILE_SHARE_WRITE`)** —— 读者不受影响,但持有期间钉住锁文件的名字与目录:CI 显示数十个套件的临时根清理因仍打开的句柄阻塞递归删除而报 EBUSY,用户删除会话目录也会撞上同一堵墙。命名信号量保住内核仲裁,且文件系统足迹为零。
+
+**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 免去 node-gyp 安装期编译,但意味着自有两个平台的锁实现及其错误映射;`fs-ext` 交付了有维护、可固定版本的 POSIX 侧,Windows 侧复用 `win32.ts` 已自有的 koffi 绑定。
+
+## Consequences
+
+跨进程排他的代价是一个 node-gyp 编译的原生依赖(`fs-ext`,已在 `pnpm-workspace.yaml` 的 `allowBuilds` 列入允许)、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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-09-04-web-clickable-link-styles.md
+2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac
+2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392

+ 34 - 0
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md

@@ -0,0 +1,34 @@
+# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs
+
+Status: implemented
+
+English | [中文](2026-09-04-web-clickable-link-styles.zh.md)
+
+## Problem
+
+Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces.
+
+## Decision
+
+One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
+
+- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
+- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
+- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
+- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
+- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
+
+Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings.
+
+## Alternatives considered
+
+- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph.
+- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category.
+- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
+- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
+
+## Consequences
+
+- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md).
+- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first.
+- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted.

+ 34 - 0
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md

@@ -0,0 +1,34 @@
+# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标
+
+Status: implemented
+
+[English](2026-09-04-web-clickable-link-styles.md) | 中文
+
+## 问题
+
+会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。
+
+## 决策
+
+会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
+
+- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
+- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
+- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
+- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
+- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
+
+覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。
+
+## 备选方案
+
+- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。
+- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
+- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
+- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
+
+## 后果
+
+- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。
+- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。
+- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。

+ 6 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md
+2026-08-28-test-temp-dir-self-cleanup.md: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6
+2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md

@@ -0,0 +1,39 @@
+# Agent Note: Unit tests remove the dsh-* temp dirs they create
+
+Status: implemented
+
+English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md)
+
+## Problem
+
+Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead.
+
+## Decision
+
+Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown:
+
+- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point.
+- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test.
+- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged.
+- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference.
+
+## Verification
+
+- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane.
+- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host.
+
+## Alternatives considered
+
+### Keep the sweep-only approach (rejected in review)
+
+Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path.
+
+### Introduce a shared temp-dir helper package
+
+Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit.
+
+## Consequences
+
+- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit.
+- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path.
+- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep.

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md

@@ -0,0 +1,39 @@
+# Agent Note:单测删除自己创建的 dsh-* 临时目录
+
+Status: implemented
+
+[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文
+
+## Problem
+
+测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。
+
+## Decision
+
+为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上:
+
+- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。
+- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。
+- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。
+- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。
+
+## Verification
+
+- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。
+- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。
+
+## Alternatives considered
+
+### 保留纯 sweep 方案(review 否决)
+
+Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。
+
+### 引入共享临时目录 helper 包
+
+未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。
+
+## Consequences
+
+- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。
+- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
+- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 5a88c49b0811e80c19e3a39cf5604a74460fc3e2
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 829b07b789c2cfe74bcaf3b9db03acffd156887b
+2026-07-24-web-gui-browser-e2e-lane.md: 2047351b5642664a800fb048acee56dc80dcc77d
+2026-07-24-web-gui-browser-e2e-lane.zh.md: dad0f69d64dbb802d3a5db097720bb97c30ea8cf

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -28,6 +28,8 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
 
 No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
 
+Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.
+
 Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
 
 ### Expected outputs

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -28,6 +28,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
 
+分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。
+
 每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
 
 ### 预期输出

+ 2 - 0
THIRD_PARTY_NOTICES.md

@@ -70,6 +70,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
 | [`fflate`](https://github.com/101arrowz/fflate) | MIT |
+| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT |
 | [`immer`](https://github.com/immerjs/immer) | MIT |
 | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
 | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
@@ -148,6 +149,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
 | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

+ 1 - 1
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 3 - 1
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -106,8 +106,10 @@ describe('session format guard through the assembled app', () => {
           version: SESSION_FORMAT_VERSION,
         })
         expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1)
+        // `session.lock` is the write handle's kernel lock file, published
+        // with the first materializing write and kept across release.
         expect((await readdir(dirname(sourcePath))).sort())
-          .toEqual(['session.jsonl', generationLogFilename(SESSION_FORMAT_VERSION, 'none')])
+          .toEqual(['session.jsonl', 'session.lock', generationLogFilename(SESSION_FORMAT_VERSION, 'none')])
       },
     })
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)

+ 7 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -82,6 +82,9 @@ async function bootWeb(
     // skills test below proves it reaches preset-composed agents.
     { id: 'skill-badge', disabled: false },
     { id: 'modules', disabled: true },
+    // The physical Connection row owns the disabled HTTP server. bootWeb
+    // supplies only its in-process registries so Host services still prove
+    // their shipped dependency graph without binding a port.
     { id: 'connection', disabled: true },
     // Export owns a Connection Fetch route, so this Host-only composition
     // disables it with the transport service above.
@@ -139,6 +142,10 @@ async function bootWeb(
   const rootConfig = join(profileDir, 'cordis.yml')
   await writeFile(rootConfig, '[]\n')
   return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => {
+    bootCtx.provide('connection', {
+      fetch: { register: () => () => {} },
+      rpc: { intercept: () => () => {} },
+    } as never)
     provideCmdline(bootCtx, { args: [], exit: () => {} })
   })
 }

+ 1 - 1
apps/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 7 - 15
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -44,17 +44,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     return page.getByRole('dialog', { name: '设置' })
   }
 
-  /** Tokenize the lane-owned preset root after general aria normalization. */
-  function withPresetRoot(snapshot: string): string {
-    const rootSuffix = `/${userRoot.split('/').pop()!}`
-    return snapshot.split('\n').map((line) => {
-      const rootStart = line.indexOf(rootSuffix)
-      if (rootStart === -1) return line
-      const pathStart = line.lastIndexOf(' ', rootStart) + 1
-      return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
-    }).join('\n')
-  }
-
   beforeAll(async () => {
     userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
     scaffold = await launchWebScaffold({
@@ -76,6 +65,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(userRoot, { recursive: true, force: true })
   })
 
   it('offers the roster with copy as the only way to create', async () => {
@@ -146,8 +136,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     // The copy dialog is detached, so the settings dialog is the only one
     // left (it names itself via aria-labelledby, which a CSS attribute
     // selector cannot address).
-    const snapshot = withPresetRoot(
-      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
+      replacements: [[userRoot, '{{presetRoot}}']],
+    })
     await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
     expect(snapshot).toContain('{{presetRoot}}/my-agent')
 
@@ -196,8 +187,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     await dialog.getByRole('button', { name: 'Agent 预设' }).click()
     await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
 
-    const snapshot = withPresetRoot(
-      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
+      replacements: [[userRoot, '{{presetRoot}}']],
+    })
     await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
     // Both damage shapes surface as marked, unselectable, uncopyable cards
     // that still carry their metadata and the discovery-reported reason.

+ 2 - 1
apps/web/tests/agent-preset-selection.e2e.ts

@@ -9,7 +9,7 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { fileURLToPath } from 'node:url'
-import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(presetRoot, { recursive: true, force: true })
   })
 
   it('offers the chip on the new-session screen, beside the workspace picker', async () => {

+ 4 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -90,7 +90,6 @@ async function openSeed(page: Page): Promise<void> {
   const resultCount = await results.count()
   if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
   await results.click()
-  await results.click()
   await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
     .last().waitFor({ timeout: 30_000 })
   await nextPaint(page)
@@ -174,6 +173,10 @@ describe('web e2e: long Chat interaction contract', () => {
 
   it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
+    await expect.poll(
+      () => scaffold.ctx.agents.get(SessionId(SESSION_ID)) !== undefined,
+      { timeout: 10_000 },
+    ).toBe(true)
     const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
     if (source === undefined) throw new Error('seeded long-history agent is not attached')
 

+ 431 - 0
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -0,0 +1,431 @@
+// Web e2e gallery: every clickable link and artifact form the chat renders,
+// in one settled keyless turn — the regression anchor for unifying link
+// styles. One fixture turn produces:
+// - prose: Markdown link, reference-style link, mailto link, inline-code URL,
+//   produced-file mention, plus inert contrasts (ambiguous basename, unwritten
+//   file, command code, URL-with-flags code, javascript: destination,
+//   footnote superscript, remote image, fenced code block with its copy chrome)
+// - artifacts: seven produced files (chips overflow into the "+N" remainder
+//   and the show-in-folder affordance) with a failed write excluded
+// - tool rows: write/edit/str_replace_editor/read file links, a failure row
+//   without one, and — expanded — the web-search source links (one non-http
+//   source stays inert) and answer link, the web-fetch URL, the read card's
+//   inert path label and fold toggle, the diff card's inert path header, the
+//   grep card's fold-only file headers, a failing bash card's exit status, and
+//   a generic tool card's IN/OUT surfaces.
+// Image thumbnails and subagent rows stay out: the first needs real attachment
+// bytes (image-display.expected.e2e.ts owns that route) and the second needs a
+// child session (subagent-conversation owns it).
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
+// The golden holds the show-in-folder affordance; pin the native-opener
+// capability so headless Linux CI and desktop developer hosts expose the same
+// UI branch (same pin as produced-files.e2e.ts, whose overlay this shares).
+const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'clickable-links-gallery-web-e2e'
+const DONE = 'LINK_GALLERY_DONE'
+
+const GUIDE_URL = 'https://docs.example.test/guide'
+const API_URL = 'https://docs.example.test/api'
+const RELEASES_URL = 'https://docs.example.test/releases'
+const MAILTO_URL = 'mailto:owner@example.test'
+const SOURCE_URL = 'https://docs.example.test/links'
+const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
+const FETCH_URL = 'https://docs.example.test/tokens'
+
+/** One-part text content for a built message. */
+function text(value: string): { type: 'text'; text: string }[] {
+  return [{ type: 'text', text: value }]
+}
+
+/** A settled root tool call: its call event arguments plus result content/meta. */
+interface GalleryCall {
+  name: string
+  args: Record<string, unknown>
+  result: string
+  meta?: JsonValue
+  isError?: true
+}
+
+/** The five successful writes; docs/press.md and src/tokens.css join via other tools. */
+const WRITES = ['site/report.html', 'a/style.css', 'b/style.css', 'site/index.html', 'site/app.js']
+
+/** The gallery turn's tool calls, in surface order. */
+const CALLS: GalleryCall[] = [
+  ...WRITES.map(path => ({
+    name: 'write',
+    args: { file_path: path, content: `content of ${path}\n` },
+    result: `Created ${path}`,
+  })),
+  {
+    name: 'edit',
+    args: {
+      file_path: 'src/tokens.css',
+      old_string: '--inline-code: #EBEEF2;',
+      new_string: '--inline-code: #F5F5F5;',
+    },
+    result: 'Edited src/tokens.css',
+    meta: {
+      diffs: [{
+        path: 'src/tokens.css',
+        oldText: '--inline-code: #EBEEF2;\n',
+        newText: '--inline-code: #F5F5F5;\n',
+      }],
+    },
+  },
+  {
+    name: 'str_replace_editor',
+    args: { command: 'create', path: 'docs/press.md', file_text: '# Press kit\n' },
+    result: 'Created docs/press.md',
+    meta: { diffs: [{ path: 'docs/press.md', oldText: null, newText: '# Press kit\n' }] },
+  },
+  {
+    name: 'write',
+    args: { file_path: 'c/broken.css', content: 'nope\n' },
+    result: 'permission denied: c/broken.css',
+    isError: true,
+  },
+  {
+    name: 'read',
+    args: { file_path: 'docs/guide.md' },
+    // The read card validates the model-facing envelope, not just the meta:
+    // without <path>/<type>/<content> the row falls back to the generic card.
+    result: [
+      '<path>docs/guide.md</path>',
+      '<type>file</type>',
+      '<content>',
+      ...Array.from({ length: 12 }, (_, index) =>
+        index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`),
+      '</content>',
+    ].join('\n'),
+    meta: {
+      path: 'docs/guide.md',
+      offset: 1,
+      lines: Array.from({ length: 12 }, (_, index) => ({
+        number: index + 1,
+        text: index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`,
+      })),
+      totalLines: 12,
+    },
+  },
+  {
+    name: 'grep',
+    args: { pattern: 'linkColor' },
+    result: 'nine matches across three files',
+    meta: {
+      shape: 'matches',
+      files: ['a/style.css', 'b/style.css', 'src/tokens.css'].map(path => ({
+        path,
+        matches: [3, 7, 11].map(lineNumber => ({ lineNumber, line: '  color: var(--linkColor);' })),
+      })),
+      truncated: false,
+      total: 9,
+    },
+  },
+  {
+    name: 'glob',
+    args: { pattern: '**/*.css' },
+    result: 'a/style.css\nb/style.css\nsrc/tokens.css',
+    meta: { shape: 'paths', paths: ['a/style.css', 'b/style.css', 'src/tokens.css'], truncated: false, total: 3 },
+  },
+  {
+    name: 'bash',
+    args: { command: 'ls site', description: 'List the built site' },
+    result: 'report.html\nindex.html\napp.js\n',
+  },
+  {
+    name: 'bash',
+    args: { command: 'pnpm run lint', description: 'Run the lint gate', workdir: 'site' },
+    result: 'style.css: unexpected hex literal\n[exit code: 1]',
+  },
+  {
+    name: 'web_search',
+    args: { queries: ['clickable link styles', 'produced files ui'] },
+    result: 'Two sources found.',
+    meta: {
+      truncated: false,
+      answer: `Unify links per [the guide](${GUIDE_URL}).`,
+      sources: [
+        { url: SOURCE_URL, title: 'Link styles reference', snippet: 'One cursor token, one focus ring.' },
+        { url: INERT_SOURCE_URL, title: 'Mirror spec (non-http)', snippet: 'A non-http source renders inert.' },
+      ],
+    },
+  },
+  {
+    name: 'web_fetch',
+    args: { url: FETCH_URL },
+    result: 'Design token reference page.',
+    meta: { url: FETCH_URL, statusCode: 200, truncated: false },
+  },
+  {
+    name: 'design_tokens_sync',
+    args: { source: 'design-platform.css', dryRun: false },
+    result: '{"synced":true,"tokens":12}',
+  },
+]
+
+/**
+ * Build the settled gallery turn: every call above plus a link-dense closing prose.
+ * @param imageUrl - scaffold-hosted image the prose embeds (kept same-origin so
+ *   the tripwire stays clean; the varying origin is tokenized in the golden).
+ */
+function galleryFixture(imageUrl: string): string {
+  const session = Session.create(SessionId('clickable-links-gallery-source'))
+  const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
+  session.append('turn/start', { turn: 1 })
+  const user = session.append('user/message', createUserMessage({
+    content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', {
+    title: 'Clickable links gallery',
+    messageSeqs: [user.seq],
+    source: { kind: 'fallback' },
+  })
+  session.append('step/start', { turn: 1, step: 1 })
+  const calls = CALLS.map((call, index) => ({
+    ...call,
+    callId: ToolCallId(`gallery-${String(index)}`),
+    argsJson: JSON.stringify(call.args),
+  }))
+  session.append('assistant/message', {
+    stream: [],
+    turn: 1,
+    step: 1,
+    message: createAssistantMessage({
+      content: calls.map(call => ({
+        type: 'tool-call' as const,
+        id: call.callId,
+        name: call.name,
+        arguments: call.argsJson,
+      })),
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+  }, { surfaceOp: 'append' })
+  for (const call of calls) {
+    const source = session.append('tool/call', {
+      turn: 1,
+      step: 1,
+      callId: call.callId,
+      name: call.name,
+      arguments: call.argsJson,
+    })
+    session.append('tool/result', {
+      turn: 1,
+      step: 1,
+      message: createToolResultMessage({
+        callId: call.callId,
+        content: text(call.result),
+        isError: call.isError === true,
+      }),
+      ...(call.meta === undefined ? {} : { meta: call.meta }),
+    }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
+  }
+  session.append('step/start', { turn: 1, step: 2 })
+  session.append('assistant/message', {
+    stream: [],
+    turn: 1,
+    step: 2,
+    message: createAssistantMessage({
+      content: text([
+        '## Link gallery',
+        '',
+        `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
+        + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
+        '',
+        `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
+        '',
+        'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
+        '',
+        `![Token preview](${imageUrl})`,
+        '',
+        '```css',
+        '--inline-code: #F5F5F5;',
+        '```',
+        '',
+        DONE,
+        '',
+        `[rel]: ${RELEASES_URL}`,
+        '',
+        '[^1]: Footnote references stay inert superscripts.',
+      ].join('\n')),
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 2 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+  return [
+    JSON.stringify({
+      type: 'session',
+      version: SESSION_FORMAT_VERSION,
+      id: '{{sessionId}}',
+      createdAt: 0,
+      cwd: '{{cwd}}',
+      isSeeded: false,
+      delegationDepth: 0,
+    }),
+    ...session.snapshotEvents().map(event => JSON.stringify({
+      ...event,
+      time: eventTimeOrigin + event.seq * 1_000,
+    })),
+    '',
+  ].join('\n')
+}
+
+describe('web e2e: clickable links gallery', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let imageUrl: string
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
+    imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
+    await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it.skipIf(MODE === 'record')('renders every clickable link and artifact form of the settled turn', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-clickable-links-gallery'))
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.waitFor({ timeout: 15_000 })
+    await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 10_000 })
+    await sessionRow.click()
+    await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+
+    // Markdown prose: plain, reference-style, and mailto links plus the
+    // inline-code URL are anchors; the URL-with-flags code, the javascript:
+    // destination, and plain command code stay inert.
+    const markdown = page.locator('[class*="markdown"]')
+    await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1)
+    expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1)
+    expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1)
+    const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`)
+    expect(await inlineCodeLink.count()).toBe(1)
+    expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
+    expect(await page.getByText(`curl ${API_URL}`, { exact: true }).locator('a').count()).toBe(0)
+    expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
+    expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1)
+
+    // Produced files: one unique-basename mention links; the shared basename
+    // and the unwritten file stay inert code. Seven produced paths overflow
+    // the chip row; the failed write joins neither surface.
+    const mentions = markdown.locator('code button')
+    expect(await mentions.count()).toBe(1)
+    expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
+    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
+    expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
+
+    // Tool rows: five writes, the edit, and the read carry the dotted file
+    // link; the failed write row does not, and neither does str_replace_editor
+    // — TOOL_VARIANTS has no entry for it, so it falls to the generic row with
+    // no openable path even though its create still joins the produced chips.
+    expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
+
+    // Expanded cards. The turn-process group collapses a multi-call turn, so
+    // it opens first. Rows expand via a right-edge click: the row center can
+    // land on the nested fileLink button, which would hand the path to the
+    // Host's opener.
+    await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
+    for (const row of [
+      /^Search clickable link styles/,
+      /^Fetch /,
+      /^Read docs\/guide\.md/,
+      /^Edit src\/tokens\.css/,
+      /^Grep linkColor/,
+      /Run the lint gate|pnpm run lint/,
+    ]) {
+      const toggle = page.getByRole('button', { name: row }).first()
+      await toggle.waitFor({ timeout: 10_000 })
+      const box = await toggle.boundingBox()
+      await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } })
+    }
+    // Both generic rows (the unclassified str_replace_editor and the unknown
+    // design_tokens_sync) expand to their IN/OUT surfaces.
+    const genericRows = page.getByRole('button', { name: /^Tool call/ })
+    for (let index = 0; index < await genericRows.count(); index += 1) {
+      const toggle = genericRows.nth(index)
+      const box = await toggle.boundingBox()
+      await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } })
+    }
+    const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`)
+    await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1)
+    expect(await page.locator('a[href^="ftp:"]').count()).toBe(0)
+    expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1)
+    expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2)
+
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+      .split(imageUrl).join('{{imageUrl}}')
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+
+    // The link language itself — ARIA records none of it, so pin the computed
+    // styles: link-blue 500-weight text, no underline at rest, dotted underline
+    // on hover, and a leading currentColor glyph. Light theme, so the link
+    // alias resolves to deepseek-500.
+    const LINK_BLUE = 'rgb(65, 118, 230)'
+    const styleOf = async (target: ReturnType<Page['locator']>, property: string): Promise<string> =>
+      target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property)
+    const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first()
+    const chip = page.locator('button[aria-label="Open site/report.html"]').first()
+    for (const [name, link] of [
+      ['markdown anchor', guideLink],
+      ['file mention', mentions.first()],
+      ['search source', sourceLink.first()],
+      ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
+      ['produced chip', chip],
+    ] as const) {
+      expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
+      expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
+      expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
+      expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
+    }
+    await guideLink.hover()
+    expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
+    expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')
+    expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px')
+    await chip.hover()
+    expect(await styleOf(chip, 'text-decoration-style')).toBe('dotted')
+    expect(await styleOf(chip, 'background-color')).toBe('rgba(0, 0, 0, 0)')
+    // The excluded grey affordance: tool-row file links keep their own color.
+    expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
+  }, 90_000)
+})

+ 12 - 12
apps/web/tests/command-image-envelope.expected.e2e.ts

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
-// The command image-attachment envelope over the BUILT client graph (real
+// The command attachment envelope over the BUILT client graph (real
 // bundles via AppWebEntry, keyless fixture Connection RPC): an enter
-// submission carrying composer images resolves only through a command whose
-// descriptor declares `input.images`. A non-declaring command refuses with
+// submission carrying composer attachments resolves only through a command whose
+// descriptor declares `input.attachments`. A non-declaring command refuses with
 // one composer error banner and everything retained; a declaring command
 // consumes the images — serialized through the real draft-image chain into
 // the commands/execute payload — and clears the composer on success, including
@@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
     },
   })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
   }, { timeout: 5_000 })
@@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   const textarea = await freshComposer()
   await pasteImage(textarea, 'ref.png')
 
-  // /echo is a leadingInput fixture command without `input.images`.
+  // /echo is a leadingInput fixture command without `input.attachments`.
   await pasteText(textarea, '/echo hello')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   // failures; session activity remains on its separate status live region.
   const notice = await waitFor(() => {
     const el = [...document.querySelectorAll('[role="alert"]')]
-      .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
+      .find(candidate => candidate.textContent?.includes('attachments') ?? false)
     if (el === undefined) throw new Error('composer refusal banner missing')
     return el
   }, { timeout: 5_000 })
-  expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
+  expect(notice.textContent).toBe('/echo does not accept attachments; remove them first')
   expect([...document.querySelectorAll('[role="status"]')]
-    .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
+    .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false)
   // The whole envelope is retained: draft text and the rail thumbnail.
   await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') })
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
 })
 
@@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe
   const textarea = await freshComposer()
   await pasteImage(textarea, 'goal-ref.png')
 
-  // /goal declares `input.images` in the fixture catalog; the claim submit
+  // /goal declares `input.attachments` in the fixture catalog; the claim submit
   // serializes the pasted bytes and the fixture executor admits them.
   await pasteText(textarea, '/goal rebuild the cathedral')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
 })
 
@@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async ()
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
   expect([...document.querySelectorAll('[role="alert"]')]
     .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)

+ 204 - 0
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -0,0 +1,204 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Clickable links gallery" [disabled]
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
+- button "Copy":
+  - img
+- button "16 tool calls" [expanded]:
+  - text: 16 tool calls
+  - img
+- button "Write site/report.html +1 -0":
+  - img
+  - img
+  - text: Write
+  - button "site/report.html"
+  - text: +1 -0
+- button "Write a/style.css +1 -0":
+  - img
+  - img
+  - text: Write
+  - button "a/style.css"
+  - text: +1 -0
+- button "Write b/style.css +1 -0":
+  - img
+  - img
+  - text: Write
+  - button "b/style.css"
+  - text: +1 -0
+- button "Write site/index.html +1 -0":
+  - img
+  - img
+  - text: Write
+  - button "site/index.html"
+  - text: +1 -0
+- button "Write site/app.js +1 -0":
+  - img
+  - img
+  - text: Write
+  - button "site/app.js"
+  - text: +1 -0
+- button "Edit src/tokens.css +1 -1" [expanded]:
+  - img
+  - text: Edit
+  - button "src/tokens.css"
+  - text: +1 -1
+- button "Copy"
+- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file"
+- button "Inspect"
+- button "Tool call str_replace_editor · create" [expanded]:
+  - img
+  - text: Tool call str_replace_editor · create
+- text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md"
+- button "Inspect"
+- text: Failed
+- 'button "Write permission denied: c/broken.css"':
+  - img
+  - text: "Write permission denied: c/broken.css"
+- button "Read docs/guide.md" [expanded]:
+  - img
+  - text: Read
+  - button "docs/guide.md"
+- text: docs/guide.md
+- button "Copy"
+- text: "# Link style guide guide line 2 guide line 3 guide line 4"
+- button "Expand 4 more lines": … 4 more lines
+- text: guide line 9 guide line 10 guide line 11 guide line 12
+- button "Inspect"
+- button "Grep linkColor" [expanded]:
+  - img
+  - text: Grep linkColor
+- text: 9 matches · 3 files
+- button "Copy"
+- button "a/style.css 3" [expanded]
+- text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);"
+- button "Expand 4 more result lines": … 4 more lines
+- button "src/tokens.css 3" [expanded]
+- text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);"
+- button "Inspect"
+- button "Glob **/*.css":
+  - img
+  - img
+  - text: Glob **/*.css
+- button "Bash List the built site":
+  - img
+  - img
+  - text: Bash List the built site
+- button "Failed Bash Run the lint gate" [expanded]:
+  - img
+  - text: Failed Bash Run the lint gate
+- text: Failed site pnpm run lint exit code 1
+- button "Copy"
+- text: "style.css: unexpected hex literal"
+- button "Inspect"
+- button "Search clickable link styles, produced files ui" [expanded]:
+  - img
+  - text: Search clickable link styles, produced files ui
+- paragraph:
+  - text: Unify links per
+  - link "the guide":
+    - /url: https://docs.example.test/guide
+  - text: .
+- list:
+  - listitem:
+    - link "Link styles reference":
+      - /url: https://docs.example.test/links
+    - text: One cursor token, one focus ring.
+  - listitem: Mirror spec (non-http) A non-http source renders inert.
+- button "Inspect"
+- button "Fetch https://docs.example.test/tokens" [expanded]:
+  - img
+  - text: Fetch https://docs.example.test/tokens
+- link "https://docs.example.test/tokens":
+  - /url: https://docs.example.test/tokens
+- text: HTTP 200
+- button "Inspect"
+- button "Tool call design_tokens_sync · design-platform.css" [expanded]:
+  - img
+  - text: Tool call design_tokens_sync · design-platform.css
+- text: "IN { \"source\": \"design-platform.css\", \"dryRun\": false } OUT {\"synced\":true,\"tokens\":12}"
+- button "Inspect"
+- heading "Link gallery" [level=2]
+- paragraph:
+  - text: "Docs:"
+  - link "style guide":
+    - /url: https://docs.example.test/guide
+  - text: and
+  - code:
+    - link "https://docs.example.test/api":
+      - /url: https://docs.example.test/api
+  - text: ; see
+  - link "the release notes":
+    - /url: https://docs.example.test/releases
+  - text: ", contact"
+  - link "the maintainer":
+    - /url: mailto:owner@example.test
+  - text: ", and check the fine print"
+  - superscript: "1"
+  - text: .
+- paragraph:
+  - text: "Inert contrasts:"
+  - code: curl https://docs.example.test/api
+  - text: ","
+  - code: javascript:alert(1)
+  - text: ", and"
+  - code: pnpm run build
+  - text: .
+- paragraph:
+  - text: Wrote
+  - code:
+    - button "Open site/report.html": report.html
+  - text: plus two
+  - code: style.css
+  - text: copies;
+  - code: notes.md
+  - text: untouched.
+- paragraph:
+  - img "Token preview"
+- text: css
+- button "Copy"
+- code: "--inline-code: #F5F5F5;"
+- paragraph: LINK_GALLERY_DONE
+- heading "Footnotes" [level=2]
+- list:
+  - listitem:
+    - paragraph: Footnote references stay inert superscripts. ↩
+- text: Produced
+- button "Open site/report.html": report.html
+- button "Open a/style.css": style.css
+- button "Open b/style.css": style.css
+- button "Open site/index.html": index.html
+- button "Open site/app.js": app.js
+- button "Open src/tokens.css": tokens.css
+- text: + 1 file
+- button "Show in folder"
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- button "Back to bottom":
+  - img
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}}

+ 9 - 0
apps/web/tests/expected/file-upload-round/draft.expected.md

@@ -0,0 +1,9 @@
+# Mixed composer attachment rail
+
+- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png
+- one attachment group: true
+- all cards share one row: true
+- every card is 64px high: true
+- the file card is wider than an image: true
+- overflowing cards scroll horizontally: true
+- the rail does not wrap: true

+ 10 - 0
apps/web/tests/expected/file-upload-round/history.expected.md

@@ -0,0 +1,10 @@
+# Mixed history attachment flow
+
+- source order: poem.txt > reference-1.png
+- one attachment group: true
+- file and image share one row: true
+- both cards are 64px high: true
+- the image is a 64px tile: true
+- the file card is wider than the image: true
+- the group wraps when needed: true
+- the group is right-aligned: true

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -17,6 +17,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -30,6 +30,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -42,6 +42,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -46,6 +46,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/reference-composer/order.expected.md

@@ -16,6 +16,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 3 - 0
apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md

@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: Skills
+  - option "policy-user-only user-only · Available only to user invocation" [selected]

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -35,6 +35,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -462,6 +462,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -34,6 +34,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -53,6 +53,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled.expected.md

@@ -41,6 +41,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 283 - 0
apps/web/tests/file-upload-round.e2e.ts

@@ -0,0 +1,283 @@
+// Web e2e scenario: generic file upload round trip. A real chromium picks a
+// file through the composer paperclip input; the upload RPC stores the exact
+// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged
+// reference, request assembly projects the file block to handle text, and the
+// model (replayed or live) reads the saved copy with the REAL read tool. The
+// content-addressed store makes the saved path identical across record and
+// replay once the workspace cwd is tokenized, so the recorded read arguments
+// replay verbatim against a freshly re-uploaded object.
+// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless
+// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
+const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
+const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
+const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
+const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
+const MODE = webSnapshotMode()
+
+/** The uploaded fixture file: constant bytes so record and replay share one content digest. */
+const FILE_NAME = 'poem.txt'
+const FILE_TEXT = 'UPLOAD_ROUND_OK\n'
+const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.'
+const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`)
+
+/** Browser-measured relations for the mixed composer attachment rail. */
+interface DraftRailGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly fileWider: boolean
+  readonly horizontalOverflow: boolean
+  readonly noWrap: boolean
+}
+
+/** Render stable relations instead of platform-dependent absolute coordinates. */
+function renderDraftRailGeometry(geometry: DraftRailGeometry): string {
+  return [
+    '# Mixed composer attachment rail',
+    '',
+    `- selection order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- all cards share one row: ${String(geometry.oneRow)}`,
+    `- every card is 64px high: ${String(geometry.equalHeight)}`,
+    `- the file card is wider than an image: ${String(geometry.fileWider)}`,
+    `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`,
+    `- the rail does not wrap: ${String(geometry.noWrap)}`,
+  ].join('\n')
+}
+
+/** Browser-measured relations for one durable mixed-attachment message. */
+interface HistoryAttachmentGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly imageIsTile: boolean
+  readonly fileWider: boolean
+  readonly wrapsWhenNeeded: boolean
+  readonly rightAligned: boolean
+}
+
+/** Render stable history-layout relations instead of absolute coordinates. */
+function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string {
+  return [
+    '# Mixed history attachment flow',
+    '',
+    `- source order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- file and image share one row: ${String(geometry.oneRow)}`,
+    `- both cards are 64px high: ${String(geometry.equalHeight)}`,
+    `- the image is a 64px tile: ${String(geometry.imageIsTile)}`,
+    `- the file card is wider than the image: ${String(geometry.fileWider)}`,
+    `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`,
+    `- the group is right-aligned: ${String(geometry.rightAligned)}`,
+  ].join('\n')
+}
+
+describe('web e2e: generic file upload through the real assembly', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: SessionEvent[] = []
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      // The override rescripts the recorded read arguments with a
+      // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run,
+      // and the live handle line in the request carries the current one.
+      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }),
+    })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.setViewportSize({ width: 900, height: 900 })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive'))
+    if (MODE !== 'record') {
+      // Drift guard: the committed fixture must carry exactly the drive prompt.
+      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    }
+    const input = page.locator('[data-composer-input]').first()
+    await input.waitFor({ timeout: 10_000 })
+    const modelTrigger = page.getByRole('button', { name: /^Select model, current/ })
+    await modelTrigger.click()
+    await page.getByRole('menuitem', { name: /^Model\b/ }).click()
+    await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click()
+    await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 })
+      .toContain('DeepSeek-V4-Flash-Vision-Exp')
+    const imageBytes = await readFile(IMAGE_FIXTURE)
+    // Pick through the composer's hidden file input: the upload RPC runs
+    // immediately and the pending card appears before any prompt is typed.
+    await page.locator('input[type="file"]').setInputFiles([
+      { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) },
+      ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })),
+    ])
+    await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 })
+    const rail = page.getByRole('group', { name: 'Pending attachments' })
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 })
+      .toBe(IMAGE_NAMES.length + 1)
+    const geometry = await rail.evaluate((element): DraftRailGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const boxes = cards.map(card => card.getBoundingClientRect())
+      const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0
+      const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0
+      return {
+        order: cards.map(card => card.querySelector('img')?.getAttribute('alt')
+          ?? card.querySelector<HTMLElement>('[title]')?.title ?? ''),
+        oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        fileWider: fileWidth > imageWidth,
+        horizontalOverflow: element.scrollWidth > element.clientWidth,
+        noWrap: getComputedStyle(element).flexWrap === 'nowrap',
+      }
+    })
+    await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE)
+    for (const name of IMAGE_NAMES.slice(1)) {
+      await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true })
+    }
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2)
+    await input.fill(PROMPT)
+    // Send unlocks only after the upload receipt lands (the staged file gate).
+    const send = page.getByRole('button', { name: 'Send message' })
+    await send.waitFor({ state: 'visible', timeout: 15_000 })
+    await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
+    const settled = scaffold.whenTurnSettled()
+    await send.click()
+    const restoreDeadline = Date.now() + 10_000
+    let retainedCards = 0
+    while (retainedCards === 0
+      && !sessionEvents.some(event => event.type === 'turn/start')
+      && Date.now() < restoreDeadline) {
+      await page.waitForTimeout(100)
+      retainedCards = await rail.locator(':scope > *').count()
+    }
+    if (retainedCards !== 0) {
+      const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents()
+      throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
+    }
+    const sessionId = await settled
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+  }, 200_000)
+
+  it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => {
+    const userMessage = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'user/message' }> =>
+        event.type === 'user/message' && event.data.source.kind === 'user',
+    )
+    if (userMessage === undefined) throw new Error('the replayed turn recorded no user message')
+    const fileBlock = userMessage.data.content.find(block => block.type === 'file')
+    if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block')
+    expect(fileBlock.attachment.name).toBe(FILE_NAME)
+    expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT))
+    expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/)
+
+    const readCall = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
+        event.type === 'tool/call' && event.data.name === 'read',
+    )
+    if (readCall === undefined) throw new Error('the replayed turn did not call the read tool')
+    expect(readCall.data.arguments).toContain(FILE_NAME)
+    const readResult = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
+        event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId,
+    )
+    if (readResult === undefined) throw new Error('the read call produced no durable result')
+    const content = readResult.data.message.content[0]
+    expect(content.isError).toBe(false)
+    expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
+      .toContain('UPLOAD_ROUND_OK')
+
+    const turnEnds = sessionEvents.filter(event => event.type === 'turn/end')
+    expect(turnEnds.length).toBe(1)
+    expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+  })
+
+  it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria'))
+    await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 })
+      .toBeGreaterThanOrEqual(1)
+    await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => {
+    const groups = page.locator('[data-message-attachments]')
+    await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1)
+    const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const renderedCards = cards.map((card) => {
+        const box = card.getBoundingClientRect()
+        return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement
+          ? card.firstElementChild
+          : card
+      })
+      const boxes = renderedCards.map(card => card.getBoundingClientRect())
+      const imageIndex = cards.findIndex(card => card.querySelector('img') !== null)
+      const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt')
+      const imageBox = boxes[imageIndex]
+      const fileBox = boxes[fileIndex]
+      return {
+        order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''),
+        oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5,
+        fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width,
+        wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap',
+        rightAligned: getComputedStyle(element).justifyContent === 'flex-end',
+      }
+    })
+    await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
+  })
+
+  it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+    await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(
+      page,
+      '[data-trajectory-row-key][aria-label*="Files ×1"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
+    ])
+  })
+})

+ 11 - 2
apps/web/tests/goal-command-presentation.e2e.ts

@@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => {
     const typography = await commandInput.evaluate((element) => {
       const bubble = element.firstElementChild?.firstElementChild
       if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
+      const chip = bubble.querySelector('[data-ref-chip="command"]')
+      if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing')
       const rootStyle = getComputedStyle(element)
       const bubbleStyle = getComputedStyle(bubble)
+      const chipStyle = getComputedStyle(chip)
       return {
         fontFamily: bubbleStyle.fontFamily,
         parentFontFamily: rootStyle.fontFamily,
         fontSize: bubbleStyle.fontSize,
         lineHeight: bubbleStyle.lineHeight,
+        chipText: chip.textContent,
+        chipFontFamily: chipStyle.fontFamily,
+        chipFontSize: chipStyle.fontSize,
       }
     })
-    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
-    expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
+    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' })
+    // The bubble reads in the body face like a user bubble; only the command
+    // chip carries the code face that marks the echoed token as a command.
+    expect(typography.fontFamily).toBe(typography.parentFontFamily)
+    expect(typography.chipFontFamily).not.toBe(typography.fontFamily)
     const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
     await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
     expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)

+ 12 - 16
apps/web/tests/image-display.expected.e2e.ts

@@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   // The rail is an accessible group holding the draft thumbnail (queried via
   // DOM: jsdom's a11y-visibility computation hides the composer subtree).
   const rail = await waitFor(() => {
-    const el = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const el = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (el === null) throw new Error('attachment rail missing')
     return el
   }, { timeout: 5_000 })
@@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   if (remove.length !== 2) throw new Error('remove buttons missing')
   for (const button of remove) fireEvent.click(button)
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   })
 
-  // An unsupported file announces a transient toast (the inline strip is
-  // gone) and the banner dismisses itself after its hold-and-fade lifetime.
+  // A non-image paste follows the generic-file path and remains in the
+  // composer as a file card.
   fireEvent.paste(textarea, {
     clipboardData: {
       items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
       getData: () => '',
     },
   })
-  const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported'
-  const toast = await screen.findByText(unsupportedMessage)
-  expect(toast.closest('[role="alert"]')).not.toBeNull()
-  await waitFor(() => {
-    expect(screen.queryByText(unsupportedMessage)).toBeNull()
-  }, { timeout: 6_000 })
+  const files = await screen.findByRole('group', { name: 'Pending attachments' })
+  expect(files.textContent).toContain('notes.txt')
 })
 
 it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
@@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
   fireEvent.dragEnter(document.body, { dataTransfer })
   const overlay = await screen.findByRole('status')
-  expect(overlay.textContent).toContain('Drag images here to add them')
+  expect(overlay.textContent).toContain('Drag files or images here to add them')
   await waitFor(() => {
-    expect(overlay.textContent).toContain('Up to 20 images, 5MB each')
+    expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each')
   })
 
   // Dropping on the transcript area (not the composer card) lands in the rail.
   fireEvent.drop(document.body, { dataTransfer })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing after page drop')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png'])
   }, { timeout: 5_000 })
@@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const limitMessage = 'A message can include up to 20 images'
   const banner = await screen.findByText(limitMessage)
   expect(banner.closest('[role="alert"]')).not.toBeNull()
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
 })
 
@@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
     },
   })
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
   })
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
       "text": "Image sides must be at most 2000px; downscale it and try again",
     }
   `)
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
 })

+ 16 - 3
apps/web/tests/message-actions.e2e.ts

@@ -1,7 +1,8 @@
 // Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
-// completed-turn-tail fork case (zero model calls) and pins the settled
-// conversation aria after the footers are focus-revealed — the surface package
-// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
+// completed-turn-tail fork case with an unchanged resume header (zero model
+// calls) and pins the settled conversation aria after the footers are
+// focus-revealed — the surface package jsdom tests cannot substitute for
+// (docs/testing.md snapshot rule).
 import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -70,6 +71,10 @@ function completedTailFixture(raw: string): string {
       },
     }
   })
+  const inheritedHeader = kept.findLast(event => event.type === 'request/header')
+  if (inheritedHeader?.type !== 'request/header') {
+    throw new Error('borrowed recording has no request header')
+  }
   let seq = (kept.at(-1)?.seq ?? -1) + 1
   let time = (kept.at(-1)?.time ?? -1) + 1
   const at = (event: Record<string, unknown>): { seq: number; time: number } & Record<string, unknown> => ({
@@ -109,6 +114,7 @@ function completedTailFixture(raw: string): string {
       surfaceOp: 'append',
     }),
     at({ type: 'step/start', data: { turn: 2, step: 1 } }),
+    at({ type: 'request/header', data: { header: inheritedHeader.data.header, reason: 'resume' } }),
     at({
       type: 'assistant/message',
       data: {
@@ -149,6 +155,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
     const raw = completedTailFixture(await readFile(SEED, 'utf8'))
     expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
+    expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
+      ? [event.data.reason]
+      : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
     await seedSession(scaffold, raw, SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -172,6 +181,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await sessionRow.click()
     await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
     await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    await expect.poll(
+      () => page.getByRole('button', { name: 'System prompt', exact: true }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
 
     // Focus-reveal the footers (hover:hover keeps them opacity-hidden until
     // hover/focus-within). Branch renders only under assistant answers — user

+ 4 - 6
apps/web/tests/navigation-panes.e2e.ts

@@ -208,14 +208,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
 
     await result.click()
-    // Search navigation addresses the session, not a specific event, and the
-    // query remains until the user explicitly clears it.
-    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
+    // Search navigation returns to the browser with the opened Session row exposed.
+    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
+    const selectedRow = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"][aria-selected="true"]')
+    await expect.poll(() => selectedRow.count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
-    await page.getByRole('button', { name: 'Clear search' }).click()
-    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
-    await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
   }, 90_000)
 
   it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {

+ 46 - 12
apps/web/tests/scaffold.ts

@@ -192,7 +192,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 const REPLAY_PROVIDERS = [{
   id: 'deepseek-official',
   name: 'DeepSeek',
-  models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
+  models: [
+    { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
+    {
+      id: 'deepseek-v4-flash-vision-exp',
+      name: 'DeepSeek-V4-Flash-Vision-Exp',
+      contextWindow: 1_000_000,
+      inputModalities: ['text', 'image'] as const,
+      defaultMaxTokens: 256_000,
+      reasoningEfforts: ['off', 'low', 'high', 'max'],
+      defaultReasoningEffort: 'high',
+    },
+  ],
 }]
 
 /**
@@ -819,6 +830,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
             replayFixture,
             mode,
             `http://${browserHost}:${port}`,
+            harnessHome,
           )
         } catch (error) {
           failures.push(error)
@@ -953,6 +965,7 @@ function stableSessionFixture(
   session: Session,
   existing: string,
   workspaceCwd: string,
+  harnessHome: string,
 ): string {
   const prepared = prepareSessionSnapshotFixtureForComparison(
     normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd),
@@ -965,6 +978,7 @@ function stableSessionFixture(
     })
   const fresh = scrubSessionSnapshot(stabilized)
     .split(session.id).join('{{sessionId}}')
+    .split(harnessHome).join('{{harnessHome}}')
   const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
   if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
   return stable
@@ -975,6 +989,7 @@ async function assertReplaySession(
   fixturePath: string,
   mode: WebSnapshotMode,
   webUrl: string,
+  harnessHome: string,
 ): Promise<void> {
   let expected = await readFile(fixturePath, 'utf8')
   const fixtureDir = dirname(fixturePath)
@@ -997,7 +1012,7 @@ async function assertReplaySession(
   if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`)
   const actual = rawSessionLog(session)
   if (mode === 'refresh' && writesCurrentSessionFixtures(manifest, mode)) {
-    expected = stableSessionFixture(session, expected, sessionCwd)
+    expected = stableSessionFixture(session, expected, sessionCwd, harnessHome)
     expectedPath = recordedSessionFixturePath(fixturePath, session.header.version)
     await writeFile(expectedPath, expected)
   }
@@ -1010,8 +1025,11 @@ async function assertReplaySession(
     sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [],
     cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0',
   }
-  expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`)
-    .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0])
+  const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot)
 
   if (manifest.header?.pin !== true) return
   const normalizePrompt = (value: string): string => value
@@ -1032,7 +1050,7 @@ async function assertReplaySession(
 
 /**
  * Record-mode fixture write-back: harvest the live session, scrub request
- * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque
+ * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque
  * identities with typed relationship-preserving tokens, and write the fixture.
  * A manifest-retained historical generation makes the write-back a no-op.
  * @param scaffold - the record-mode scaffold.
@@ -1048,7 +1066,12 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId,
   const target = recordedSessionFixturePath(fixturePath, agent.session.header.version)
   const existingPath = existsSync(target) ? target : fixturePath
   const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : ''
-  await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd))
+  await writeFile(target, stableSessionFixture(
+    agent.session,
+    existing,
+    scaffold.workspaceCwd,
+    scaffold.harnessHome,
+  ))
 }
 
 /**
@@ -1078,8 +1101,8 @@ export function fixtureIdentity(
 
 /**
  * Realize a recorded seed fixture against one scaffold: substitute the
- * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
- * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
+ * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the
+ * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
  * price content exactly as the host will fold it) and still pass the result
  * through {@link seedSession}.
  * @param scaffold - the booted scaffold whose workspace the seed targets.
@@ -1104,6 +1127,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
         .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`)
         .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) =>
           fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal)))
+        .split('{{harnessHome}}').join(scaffold.harnessHome)
         .split('{{cwd}}').join(scaffold.workspaceCwd)
       return result
     })
@@ -1373,20 +1397,30 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st
  * @param selector - the region locator selector.
  * @param workspaceCwd - normalization input.
  * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
- *   for a region whose rows are dated from live wall-clock state.
+ *   for a region whose rows are dated from live wall-clock state;
+ *   `replacements` tokenizes scenario-owned values before generic normalization.
  * @returns the stable normalized snapshot.
  */
 export async function captureStableAria(
   page: Page,
   selector: string,
   workspaceCwd: string,
-  options: { normalizeAge?: boolean } = {},
+  options: {
+    normalizeAge?: boolean
+    replacements?: readonly (readonly [value: string, token: string])[]
+  } = {},
 ): Promise<string> {
   const region = page.locator(selector).first()
   const age = options.normalizeAge === true
-  let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
+  const normalize = (snapshot: string): string => {
+    for (const [value, token] of options.replacements ?? []) {
+      snapshot = snapshot.split(value).join(token)
+    }
+    return normalizeAria(snapshot, workspaceCwd, age)
+  }
+  let previous = normalize(await region.ariaSnapshot())
   await expect.poll(async () => {
-    const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
+    const current = normalize(await region.ariaSnapshot())
     const stable = current === previous
     previous = current
     return stable

+ 11 - 2
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -18,10 +18,11 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md')
 const MODE = webSnapshotMode()
 
 interface SeedSkill {
@@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => {
 
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+
+    // Discovery needs no prefix: an in-order subsequence of one skill name
+    // ranks that skill alone, through the ranker the command group uses.
+    await writeComposerDraft(page, input, '/plcyusr')
+    await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1)
+    expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1)
+    const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md'])
   })
 })

+ 4 - 0
apps/web/tests/skill-user-invoke.e2e.ts

@@ -130,6 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     await settled
     const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
     await process.waitFor({ state: 'visible', timeout: 10_000 })
+    // The chip derives from the step's logged injection, so it must survive
+    // every later Node rebuild of the Turn (process publication, turn close).
+    expect(await bubble.count()).toBe(1)
+    expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
     await expandOwningTurnProcess(page, injectionFlow)
     const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
     await injectionRow.click()

+ 2 - 0
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -27,6 +27,8 @@
 - textbox "Message or run a task, run / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":
   - text: streaming-fence-highlight-test/streaming-fence

+ 2 - 2
apps/web/tests/submission-echo.e2e.ts

@@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
     },
   })
   await waitFor(() => {
-    if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) {
+    if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) {
       throw new Error('attachment rail missing')
     }
   }, { timeout: 5_000 })
@@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
   expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
   expect(composer.textContent).toBe('')
   expect(composer.getAttribute('contenteditable')).toBe('true')
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
 
   // The fixture's durable user/message (source.rpcId echoes the prompt
   // requestId) replaces the echo: one bubble, no marker left, and the image

+ 6 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -155,10 +155,15 @@ async function rowTop(page: Page, key: string): Promise<number | null> {
 
 async function loadToFirstTurn(page: Page): Promise<void> {
   const marker = FIXTURE.markers.user(1)
+  const loadMore = page.locator('[data-history-load] button')
   for (let attempt = 0; attempt < 12; attempt += 1) {
-    await scrollToRatio(page, 0)
     if (await page.getByText(marker, { exact: false }).count() > 0) return
+    await expect.poll(() => loadMore.evaluateAll(buttons =>
+      buttons.length === 0 || !(buttons[0] as HTMLButtonElement).disabled,
+    ), { timeout: 15_000 }).toBe(true)
     const before = await logicalRows(page)
+    await scrollToRatio(page, 0)
+    if (await page.getByText(marker, { exact: false }).count() > 0) return
     const anchor = await firstVisibleRow(page)
     await expect.poll(async () => ({
       marker: await page.getByText(marker, { exact: false }).count() > 0,

+ 2 - 0
apps/web/tsconfig.json

@@ -63,6 +63,7 @@
     "tests/composer-draft-scroll.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
+    "tests/file-upload-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/message-feedback.e2e.ts",
     "tests/message-feedback-layout.e2e.ts",
@@ -72,6 +73,7 @@
     "tests/math-rendering.e2e.ts",
     "tests/markdown-cjk-strong.e2e.ts",
     "tests/markdown-inline-code-links.e2e.ts",
+    "tests/clickable-links-gallery.e2e.ts",
     "tests/queue-actions.e2e.ts",
     "tests/queue-image.e2e.ts",
     "tests/skill-invocation-policy.e2e.ts",

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor