Просмотр исходного кода

Merge pull request #3986 from deepseek-harness/worktree/code-diff-card

代码 diff 卡片
CreatixChu 2 недель назад
Родитель
Сommit
b784e586ef
100 измененных файлов с 2271 добавлено и 797 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  5. 53 0
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  6. 53 0
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  7. 1 0
      AGENTS.md
  8. 1 0
      apps/web/package.json
  9. 125 0
      apps/web/tests/changed-files-turn.e2e.ts
  10. 5 0
      apps/web/tests/changed-files-turn.overlay.yml
  11. 7 11
      apps/web/tests/clickable-links-gallery.e2e.ts
  12. 9 0
      apps/web/tests/clickable-links-gallery.overlay.yml
  13. 0 8
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  14. 2 3
      apps/web/tests/present-svg.e2e.ts
  15. 2 2
      apps/web/tests/produced-file-mentions.e2e.ts
  16. 0 216
      apps/web/tests/produced-files.e2e.ts
  17. 0 9
      apps/web/tests/produced-files.overlay.yml
  18. 6 2
      apps/web/tests/scaffold.ts
  19. 27 16
      apps/web/tests/sidebar-right.e2e.ts
  20. 1 1
      apps/web/tsconfig.json
  21. 2 2
      docs/capability-seams.i18n.yaml
  22. 4 0
      docs/capability-seams.md
  23. 4 0
      docs/capability-seams.zh.md
  24. 2 2
      docs/config-catalog.i18n.yaml
  25. 22 2
      docs/config-catalog.md
  26. 21 1
      docs/config-catalog.zh.md
  27. 2 2
      docs/event-producer-consumer.i18n.yaml
  28. 5 5
      docs/event-producer-consumer.md
  29. 5 5
      docs/event-producer-consumer.zh.md
  30. 2 2
      docs/module-graph.i18n.yaml
  31. 16 8
      docs/module-graph.md
  32. 16 8
      docs/module-graph.zh.md
  33. 2 2
      docs/persistence-catalog.i18n.yaml
  34. 58 18
      docs/persistence-catalog.md
  35. 58 18
      docs/persistence-catalog.zh.md
  36. 6 0
      docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml
  37. 48 0
      docs/persistence-changes/2026-09-14-workspace-changes-event.md
  38. 72 0
      docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json
  39. 48 0
      docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md
  40. 2 2
      docs/persistence-changes/historical-formats/README.i18n.yaml
  41. 1 1
      docs/persistence-changes/historical-formats/README.md
  42. 1 1
      docs/persistence-changes/historical-formats/README.zh.md
  43. 3 3
      docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.schema.json
  44. 154 5
      docs/persistence-schema.json
  45. 2 2
      docs/subsystems/README.i18n.yaml
  46. 1 0
      docs/subsystems/README.md
  47. 1 0
      docs/subsystems/README.zh.md
  48. 6 0
      docs/subsystems/deliverables.i18n.yaml
  49. 111 0
      docs/subsystems/deliverables.md
  50. 111 0
      docs/subsystems/deliverables.zh.md
  51. 2 2
      docs/tool-catalog.i18n.yaml
  52. 1 1
      docs/tool-catalog.md
  53. 1 1
      docs/tool-catalog.zh.md
  54. 2 2
      packages/README.i18n.yaml
  55. 1 0
      packages/README.md
  56. 1 0
      packages/README.zh.md
  57. 8 2
      packages/bundle/web-app/cordis.patch.yml
  58. 1 0
      packages/bundle/web-app/package.json
  59. 2 2
      packages/client/README.i18n.yaml
  60. 1 1
      packages/client/README.md
  61. 1 1
      packages/client/README.zh.md
  62. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  63. 17 13
      packages/client/ui-deliverables/README.md
  64. 17 13
      packages/client/ui-deliverables/README.zh.md
  65. 2 1
      packages/client/ui-deliverables/package.json
  66. 73 0
      packages/client/ui-deliverables/src/changes.ts
  67. 26 0
      packages/client/ui-deliverables/src/client/ChangedFiles.module.css
  68. 108 0
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  69. 1 1
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  70. 36 16
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  71. 0 159
      packages/client/ui-deliverables/src/client/ProducedFiles.module.css
  72. 0 61
      packages/client/ui-deliverables/src/client/ProducedFiles.tsx
  73. 64 0
      packages/client/ui-deliverables/src/client/changes-summary.ts
  74. 26 0
      packages/client/ui-deliverables/src/client/icons.tsx
  75. 18 14
      packages/client/ui-deliverables/src/client/index.ts
  76. 24 8
      packages/client/ui-deliverables/src/client/locales.ts
  77. 19 3
      packages/client/ui-deliverables/src/client/present-open.ts
  78. 29 5
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  79. 3 2
      packages/client/ui-deliverables/src/index.ts
  80. 139 42
      packages/client/ui-deliverables/src/present-open.ts
  81. 169 0
      packages/client/ui-deliverables/tests/changes-open.host.spec.ts
  82. 246 73
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  83. 16 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  84. 1 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  85. 3 3
      packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts
  86. 5 1
      packages/client/ui-deliverables/tsconfig.client.json
  87. 5 1
      packages/client/ui-deliverables/tsconfig.host.json
  88. 1 0
      packages/core/session/src/known-event-types.ts
  89. 6 0
      packages/deliverables/README.i18n.yaml
  90. 50 0
      packages/deliverables/README.md
  91. 50 0
      packages/deliverables/README.zh.md
  92. 1 1
      packages/deliverables/tool-present/README.i18n.yaml
  93. 0 0
      packages/deliverables/tool-present/README.md
  94. 0 0
      packages/deliverables/tool-present/README.zh.md
  95. 1 1
      packages/deliverables/tool-present/package.json
  96. 0 0
      packages/deliverables/tool-present/src/index.ts
  97. 0 0
      packages/deliverables/tool-present/src/types.ts
  98. 1 1
      packages/deliverables/tool-present/tests/built-errors.e2e.ts
  99. 0 0
      packages/deliverables/tool-present/tests/present.spec.ts
  100. 1 1
      packages/deliverables/tool-present/tsconfig.json

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b
-2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769
+2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf
+2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -10,13 +10,13 @@ Users need to open and edit the files produced in their workspace, including she
 
 ## Decision
 
-The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
+The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
 
 The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
 
 The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
 
-An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The [changed-files card](2026-09-11-turn-changed-files-card.md) lists the turn's recorded changes beside the delivery cards; neither surface implies final delivery.
 
 File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -10,13 +10,13 @@ Status: implemented
 
 ## 决策
 
-[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
+[present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
 
 工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
 
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
 
-经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)在交付卡片旁列出本轮记录的改动;两个表面均不表示最终交付。
 
 文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-11-turn-changed-files-card.md
+2026-09-11-turn-changed-files-card.md: 37efc24b5eaa0a20af677a0e15977e3653e2fb66
+2026-09-11-turn-changed-files-card.zh.md: ae45644f90ab536d254ae83f68f4450edcd6b01e

+ 53 - 0
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md

@@ -0,0 +1,53 @@
+# Agent Note: Turn changed-files card
+
+Status: implemented
+
+English | [中文](2026-09-11-turn-changed-files-card.zh.md)
+
+## Problem
+
+After a turn, users want to see which files the model changed and by how much. The Web turn tail listed only the paths of successful `write`, `edit`, and `str_replace_editor` calls, without line counts, and missed every file a shell command changed; the per-call diff cards in the message flow answered the question one call at a time.
+
+## Decision
+
+The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) plugin summarizes each top-level turn's changed files, announces the summary with a `workspace/changes` Session event that carries only the turn number, and serves the summary through its `workspaceChanges` service until the Session is disposed; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) reads the served summary and renders it as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible.
+
+The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
+
+Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. Nested repositories and submodules are gitlinks and are not descended into.
+
+Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
+
+The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar.
+
+The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route.
+
+The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and the tree ids a later content comparison needs live only as long as the Session in the Host process: the summaries in the recorder, the objects in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Whether to keep content across restarts, and for how many turns, is left to #3822.
+
+## Alternatives considered
+
+**Extending the mutation-call row with hunk counts** kept a second, weaker implementation beside the per-call diff cards and still missed shell edits; git sees every write regardless of the tool.
+
+**Diffing against `HEAD` at turn end** needs one command but attributes the user's uncommitted work to the turn.
+
+**A git tag or `stash create` per turn** leaves refs in the user's repository or omits untracked files; a tree written through a private index does neither.
+
+**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only.
+
+**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose.
+
+**Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime.
+
+**A snapshot object store under the Harness home with a byte bound** was the first implementation's placement: one store per repository, shared by every Session, discarded when it outgrew the bound. It survived Host restarts that the summaries no longer do, needed two configuration fields, and made a Session's snapshot fail when another Session discarded the store; a per-Session temporary directory removes all three.
+
+**An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place.
+
+**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would add shell edits to those users' card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes.
+
+## Consequences
+
+Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff.
+
+The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
+
+Focused tests cover repositories with real git, directories outside any repository, coverage classification with the metadata the tools really persist, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores.

+ 53 - 0
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md

@@ -0,0 +1,53 @@
+# Agent Note: 本轮改动文件卡片
+
+Status: implemented
+
+[English](2026-09-11-turn-changed-files-card.md) | 中文
+
+## Problem
+
+一轮结束后,用户想看到模型改了哪些文件、改了多少。Web 的轮尾只列出成功的 `write`、`edit` 与 `str_replace_editor` 调用的路径,没有行数,也漏掉所有 shell 命令改动的文件;消息流中逐调用的 diff 卡片只能一次回答一个调用。
+
+## Decision
+
+Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 插件汇总每个顶层轮次改动的文件,用只带轮号的 `workspace/changes` Session 事件宣告,并通过 `workspaceChanges` 服务提供摘要直到 Session 释放;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)读取提供的摘要并渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。
+
+记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象;把捕获的每个字节都放在工作区外学自工作区变更日志 POC(#2973),而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
+
+git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。
+
+快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
+
+列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。
+
+记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。
+
+日志有意只携带轮号。摘要、快照树以及将来做内容对比要用的 tree id 只在 Host 进程内随 Session 存活:摘要在记录器里,对象在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。是否跨重启保留内容、保留多少轮,留给 #3822。
+
+## Alternatives considered
+
+**给修改调用行补上 hunk 计数**会在逐调用 diff 卡片旁保留第二个更弱的实现,且仍然漏掉 shell 编辑;git 不管工具是什么都能看到每次写入。
+
+**轮末直接与 `HEAD` 比较**只需一条命令,但会把用户未提交的工作算到本轮。
+
+**每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。
+
+**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑。
+
+**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本。
+
+**把文件列表和行数记进事件**是第一版实现:卡片从此可以永远从日志渲染,而它打开的内容却活不了那么久。它被只做宣告的事件取代,让卡片和内容共用一个寿命。
+
+**Harness home 下带字节上限的快照对象库**是第一版实现的放置方式:每个仓库一个、所有 Session 共用、超过上限就丢弃。它能活过 Host 重启,而摘要已经不再需要这一点;它还需要两个配置字段,并且一个 Session 丢弃对象库会让另一个 Session 的快照失败。每个 Session 一个临时目录把这三点一起去掉。
+
+**用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。
+
+**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下也看到 shell 改动,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。
+
+## Consequences
+
+每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。
+
+只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
+
+聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、按工具真实持久化的元数据做覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。

+ 1 - 0
AGENTS.md

@@ -25,6 +25,7 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   terminal/             persistent terminals
   ptc-runtime/          PTC execution
   sandbox/              process confinement
+  deliverables/         turn deliverables
   fs/                   filesystem access
   lsp/                  language servers
   skill/                skill loading

+ 1 - 0
apps/web/package.json

@@ -58,6 +58,7 @@
     "vitest": "^4.1.8",
     "ws": "8.21.0",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
+    "@deepseek-ai/dsh-workspace-changes": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
   }

+ 125 - 0
apps/web/tests/changed-files-turn.e2e.ts

@@ -0,0 +1,125 @@
+/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card. */
+import { execFileSync } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {} from '@deepseek-ai/dsh-workspace-changes'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import {
+  assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
+  webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
+const FIXTURE = join(DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = '不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。'
+
+/** Seed a committed repository so the turn's own edits are the only difference between its snapshots; `*.local` stays ignored. */
+async function seedRepository(cwd: string): Promise<void> {
+  await mkdir(cwd, { recursive: true })
+  await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n')
+  await writeFile(join(cwd, 'notes.txt'), 'start\n')
+  await writeFile(join(cwd, '.gitignore'), '*.local\n')
+  const git = (...args: string[]) => execFileSync('git', ['-c', 'user.email=seed@example.com', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' })
+  git('init', '-q', '-b', 'main')
+  git('add', '-A')
+  git('commit', '-q', '-m', 'seed')
+}
+
+describe('web e2e: a git workspace turn ends with its changed files', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let cwd: string
+  let replayRoot: string | undefined
+
+  beforeAll(async () => {
+    let replayOverride: string | undefined
+    if (MODE !== 'record') {
+      replayRoot = await mkdtemp(join(tmpdir(), 'dsh-changed-files-turn-replay-'))
+      replayOverride = join(replayRoot, 'replay.override.json')
+      const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+      // Recorded absolute paths must follow each isolated Session's working directory.
+      const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
+      await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
+    }
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
+      ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
+    })
+    await seedRepository(join(scaffold.workspaceCwd, 'workspace'))
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('records the edited, created, and shell-appended files with their line counts', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.agents.get(sessionId)?.session
+    if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
+    cwd = session.header.cwd
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+
+    const announced = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
+    expect(announced, 'the turn must announce its changed files').toBeDefined()
+    if (announced === undefined) throw new Error('no changed-files announcement')
+    expect(announced.data).toEqual({ turn: 1 })
+    // The log carries only the turn; the Host serves the summary for the announcing event while the Session lives.
+    const summary = scaffold.ctx.workspaceChanges.summary(sessionId, announced.seq)
+    if (summary === undefined) throw new Error('the Host serves no summary for the announcement')
+    // app.local is ignored by the repository, so its counts come from the write call rather than git.
+    expect(summary.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
+    expect(summary.total).toBe(4)
+    for (const file of summary.files) expect(file.added).toBeGreaterThan(0)
+    expect(summary.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
+    expect(summary.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
+    expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
+
+    const card = page.locator('[data-changed-files]')
+    await card.waitFor({ state: 'visible' })
+    expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
+    expect(await card.getByRole('listitem').count()).toBe(3)
+    expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
+    // Without a Host desktop the header is a label and rows preview in the Sidebar.
+    expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0)
+    expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => {
+    await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] })
+    const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+  })
+})

+ 5 - 0
apps/web/tests/changed-files-turn.overlay.yml

@@ -0,0 +1,5 @@
+# This transcript includes the no-desktop state on every test platform, so the
+# card header stays a label and rows preview in the Sidebar.
+- id: session-controller
+  config:
+    nativeOpen: false

+ 7 - 11
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -40,8 +40,8 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery',
 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))
+// UI branch.
+const OVERLAY = fileURLToPath(new URL('./clickable-links-gallery.overlay.yml', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const DONE = 'LINK_GALLERY_DONE'
@@ -345,13 +345,12 @@ describe('web e2e: clickable links gallery', () => {
     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.
+    // and the unwritten file stay inert code. The seeded session carries no
+    // recorded change summary, so no changed-files card follows the prose.
     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('Files changed', { exact: true }).count()).toBe(1)
-    expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
+    expect(await page.locator('[data-changed-files]').count()).toBe(0)
     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
@@ -408,13 +407,11 @@ describe('web e2e: clickable links gallery', () => {
     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')
@@ -425,9 +422,8 @@ describe('web e2e: clickable links gallery', () => {
     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)')
+    await mentions.first().hover()
+    expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
     // 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)

+ 9 - 0
apps/web/tests/clickable-links-gallery.overlay.yml

@@ -0,0 +1,9 @@
+# The gallery asserts native open actions without launching them. Pin the
+# capability so headless Linux CI and desktop developer hosts expose the same
+# UI branch; platform opener behavior belongs to the Host unit tests.
+- id: session-controller
+  config:
+    nativeOpen: true
+- id: settings-controller
+  config:
+    nativeOpen: true

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

@@ -169,14 +169,6 @@
 - list:
   - listitem:
     - paragraph: Footnote references stay inert superscripts. ↩
-- text: Files changed
-- 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 "Copy":
   - img
 - button "Good response":

+ 2 - 3
apps/web/tests/present-svg.e2e.ts

@@ -106,9 +106,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
     await card.waitFor({ state: 'visible' })
     expect(await card.count()).toBe(1)
     expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
-    if (await page.locator('[data-produced-files-row]').count() > 0) {
-      expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1)
-    }
+    // The scaffold workspace is not a git repository, so the changed-files card lists the written SVG from the write call alone.
+    expect(await page.locator('[data-changed-files]').count()).toBe(1)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 2 - 2
apps/web/tests/produced-file-mentions.e2e.ts

@@ -151,8 +151,8 @@ describe('web e2e: inline-code mentions of produced files', () => {
     expect(await mentions.first().innerText()).toBe('report.html')
     expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    // The turn still ends with its produced-files row (all three writes).
-    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
+    // Mentions resolve from the mutation calls alone; without a recorded summary no card follows.
+    expect(await page.locator('[data-changed-files]').count()).toBe(0)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 0 - 216
apps/web/tests/produced-files.e2e.ts

@@ -1,216 +0,0 @@
-// Web e2e scenario: the single-line produced-files summary a finished turn
-// ends with. Cold-seeds ten writes (zero model calls), then verifies the real
-// assembled lane adapts from a coarse width budget and offers no folder
-// handoff: chips open in the right Sidebar's text preview, which has no
-// directory form, so the row shows nothing rather than a dead button.
-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 {} from '@deepseek-ai/dsh-session-title'
-import {
-  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
-} from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
-
-const MODE = webSnapshotMode()
-const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
-const SEED_ID = 'produced-files-web-e2e'
-const DONE = 'PRODUCED_FILES_DONE'
-
-/** Ten varied names exercise estimated prefix selection and CSS shrinking. */
-const PRODUCED = [
-  '关于我.md',
-  'index.html',
-  'long-generated-experience-specification-for-produced-files-overflow.md',
-  'styles.css',
-  'app.ts',
-  'schema.json',
-  'README.md',
-  'preview.svg',
-  'notes.txt',
-  'manifest.yaml',
-] as const
-
-/** Build one settled turn whose successful write calls carry ten locations. */
-function producedFixture(): string {
-  const session = Session.create(SessionId('produced-files-source'))
-  const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
-  session.append('turn/start', { turn: 1 })
-  const user = session.append('user/message', createUserMessage({
-    content: [{ type: 'text', text: 'Create the site files.' }],
-    source: { kind: 'user' },
-  }), { surfaceOp: 'append' })
-  session.append('session/title', {
-    title: 'Produced files overflow', messageSeqs: [user.seq], source: { kind: 'fallback' },
-  })
-  session.append('step/start', { turn: 1, step: 1 })
-  const calls = PRODUCED.map((path, index) => ({
-    path,
-    callId: ToolCallId(`produced-files-${String(index)}`),
-    args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
-  }))
-  session.append('assistant/message', {
-    stream: [],
-    turn: 1,
-    step: 1,
-    message: createAssistantMessage({
-      content: calls.map(call => ({
-        type: 'tool-call' as const,
-        id: call.callId,
-        name: 'write',
-        arguments: call.args,
-      })),
-      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: 'write', arguments: call.args,
-    })
-    session.append('tool/result', {
-      turn: 1,
-      step: 1,
-      message: createToolResultMessage({
-        callId: call.callId,
-        content: [{ type: 'text', text: `Created ${call.path}` }],
-        isError: false,
-      }),
-    }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
-  }
-  session.append('step/end', { turn: 1, step: 1 })
-  session.append('step/start', { turn: 1, step: 2 })
-  session.append('assistant/message', {
-    stream: [],
-    turn: 1,
-    step: 2,
-    message: createAssistantMessage({
-      content: [{ type: 'text', text: `Created the site.\n\n${DONE}` }],
-      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: a finished turn ends with the files it produced', () => {
-  let scaffold: WebScaffold
-  let browser: Browser
-  let page: Page
-  let tripwire: ReturnType<typeof watchConsole>
-
-  beforeAll(async () => {
-    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
-    await seedSession(scaffold, producedFixture(), SEED_ID)
-    browser = await chromium.launch()
-    page = await newEnglishPage(browser)
-    // Keep the responsive sidebar available while selecting the cold seed;
-    // the assertion itself narrows the conversation after navigation.
-    await page.setViewportSize({ width: 1800, height: 900 })
-    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')('adapts a ten-file summary without leaving one line', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
-    const groupRow = page.locator('[role="treeitem"]').first()
-    await groupRow.waitFor({ timeout: 15_000 })
-    if (await groupRow.getAttribute('aria-expanded') !== 'true') 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)
-    const row = page.locator('[data-produced-files-row]')
-    await row.waitFor({ timeout: 15_000 })
-    const chips = row.getByRole('button')
-    await expect.poll(() => chips.count()).toBe(6)
-    await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
-
-    await page.setViewportSize({ width: 750, height: 900 })
-    await page.evaluate(async () => { await document.fonts.ready })
-    await page.waitForFunction(() => {
-      const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
-      if (frame === null) return false
-      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
-      // The responsive sidebar's settled collapsed track is 56px.
-      return tracks[0] === 56 && tracks.at(-1) === 0
-        && frame.getAnimations().every(animation =>
-          animation.playState === 'finished' || animation.playState === 'idle')
-    }, undefined, { timeout: 10_000 })
-    await expect.poll(() => chips.count()).toBe(4)
-    const laneWidth = await row.evaluate(element => element.clientWidth)
-    // Keep font-metric differences away from the 479px and 583px container-query edges.
-    expect(laneWidth).toBeGreaterThan(503)
-    expect(laneWidth).toBeLessThan(559)
-    expect(await chips.nth(0).innerText()).toBe('关于我.md')
-    expect(await chips.nth(1).innerText()).toBe('index.html')
-    expect(await chips.nth(3).innerText()).toBe('styles.css')
-    await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true)
-    // Chips open in the right Sidebar's text preview, and a directory is not
-    // something that preview can show, so the row offers no folder action.
-    expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
-    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
-
-    const turnSpacing = await page.evaluate((done) => {
-      const requiredElement = <T extends Element>(value: T | null | undefined, name: string): T => {
-        if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`)
-        return value
-      }
-      const answer = requiredElement(
-        [...document.querySelectorAll<HTMLElement>('[data-chat-flow-kind="assistant-step"]')]
-          .find(element => element.textContent?.includes(done)),
-        'final answer',
-      )
-      const producedRow = requiredElement(
-        document.querySelector<HTMLElement>('[data-produced-files-row]'),
-        'produced row',
-      )
-      const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root')
-      const turnTail = requiredElement(producedRoot.closest<HTMLElement>('[data-turn-tail]'), 'turn tail')
-      const actions = requiredElement(
-        turnTail.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.parentElement,
-        'action row',
-      )
-      const answerRect = answer.getBoundingClientRect()
-      const producedRect = producedRoot.getBoundingClientRect()
-      const actionsRect = actions.getBoundingClientRect()
-      return {
-        answerToProduced: producedRect.top - answerRect.bottom,
-        producedToActions: actionsRect.top - producedRect.bottom,
-      }
-    }, DONE)
-    expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1)
-    expect(turnSpacing.producedToActions).toBeCloseTo(20, 1)
-
-    const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
-      elements.map(element => element.getBoundingClientRect().top))
-    expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)
-    const geometry = await row.evaluate(element => ({
-      clientWidth: element.clientWidth, scrollWidth: element.scrollWidth,
-    }))
-    expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth)
-
-    expect(tripwire.pageErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
-  }, 90_000)
-})

+ 0 - 9
apps/web/tests/produced-files.overlay.yml

@@ -1,9 +0,0 @@
-# The summary test asserts the native-folder action without launching it. Pin
-# the capability so headless Linux CI and desktop developer hosts expose the
-# same UI branch; platform opener behavior belongs to the Host unit tests.
-- id: session-controller
-  config:
-    nativeOpen: true
-- id: settings-controller
-  config:
-    nativeOpen: true

+ 6 - 2
apps/web/tests/scaffold.ts

@@ -38,6 +38,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
 import {
   captureExpectedWorkspaceSnapshot,
   captureWorkspaceSnapshot,
+  type CaptureWorkspaceSnapshotOptions,
   assertSessionFixtureVersion,
   formatSystemPromptSnapshot,
   formatToolSchemasSnapshot,
@@ -136,13 +137,16 @@ export function webSnapshotMode(): WebSnapshotMode {
  * Compare a session-driven Web scenario's complete workspace with its committed independent expected state.
  * @param scenarioDir - Absolute recorded-session scenario directory.
  * @param workspaceRoot - Absolute cwd used by the controlled session.
+ * @param options - Root entries the scenario owns outside the expected state, such as a `.git` directory it initialized.
  */
-export async function assertFinalWorkspaceSnapshot(scenarioDir: string, workspaceRoot: string): Promise<void> {
+export async function assertFinalWorkspaceSnapshot(
+  scenarioDir: string, workspaceRoot: string, options: CaptureWorkspaceSnapshotOptions = {},
+): Promise<void> {
   const manifestPath = join(scenarioDir, 'snapshot.yml')
   const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
   expect(manifest.workspace?.final, `${manifest.scenario ?? scenarioDir}: mutating Web scenario declares workspace.final`)
     .toBe(true)
-  const actual = await captureWorkspaceSnapshot(workspaceRoot)
+  const actual = await captureWorkspaceSnapshot(workspaceRoot, options)
   const expected = await captureExpectedWorkspaceSnapshot(join(scenarioDir, 'workspace.expected'))
   expect(actual, `${manifest.scenario ?? scenarioDir}: complete final workspace`).toEqual(expected)
 }

+ 27 - 16
apps/web/tests/sidebar-right.e2e.ts

@@ -186,6 +186,15 @@ async function setPanelWidth(page: Page, target: number): Promise<void> {
 }
 
 /** Tab titles inside one container, in strip order. */
+/**
+ * The closing prose's inline-code mention of the seeded file. The changed-files
+ * card below the prose offers the same open under the same accessible name, so
+ * the first match in document order is the prose's.
+ */
+function proseChip(root: Page): Locator {
+  return root.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).first()
+}
+
 async function tabTitles(root: Locator): Promise<string[]> {
   return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
 }
@@ -266,9 +275,10 @@ describe('web e2e: shipped right Sidebar', () => {
         source: { kind: 'user' },
       }), { surfaceOp: 'append' })
       agent.session.append('step/start', { turn: 1, step: 1 })
-      // A successful mutation is what makes the turn tail offer a produced-file
-      // chip — the product's own way into the Sidebar. The file is written for
-      // real because the preview reads it through the workspace endpoint.
+      // A successful mutation is what lets the closing prose link the file's
+      // inline-code mention — the product's own way into the Sidebar. The file
+      // is written for real because the preview reads it through the workspace
+      // endpoint.
       //
       // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
       // relative paths against the header-derived workspace root. Writing
@@ -298,7 +308,7 @@ describe('web e2e: shipped right Sidebar', () => {
         step: 1,
         message: createMessage({
           role: 'assistant',
-          content: [{ type: 'text', text: 'Ready.' }],
+          content: [{ type: 'text', text: `Ready. Wrote \`${SAMPLE_NAME}\`.` }],
           source: { kind: 'model', provider: 'fixture', model: 'fixture' },
         }),
       }, { surfaceOp: 'append' })
@@ -730,10 +740,10 @@ describe('web e2e: shipped right Sidebar', () => {
         if (request.url().includes('workspaceFiles')) wire.sent += 1
       })
 
-      // The product's own entry point: the turn tail's produced-file chip. It
+      // The product's own entry point: the closing prose's file mention. It
       // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the
       // text type claims the address.
-      const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` })
+      const chip = proseChip(page)
       await chip.click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
 
@@ -749,17 +759,18 @@ describe('web e2e: shipped right Sidebar', () => {
         .waitFor({ timeout: 15_000 })
         .catch(() => { throw new Error(`preview never settled; wire=${JSON.stringify(wire)}`) })
       expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
-      // The whole batch-E chain in one frame: a produced-file chip in the
+      // The whole batch-E chain in one frame: a file mention in the
       // conversation, the tab it opened, and the file's real content read over
       // the workspace endpoint.
       await shot(page, '06-produced-chip-to-preview')
 
       // The directory scenario's V1 behaviour, asserted in the shipped product:
-      // there is no folder affordance at all. `openFile('.')` would name a
-      // directory, which a text preview correctly refuses, and the native opener
-      // it used to reach is gone — so the row offers nothing rather than a
-      // button that always fails.
-      expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
+      // the prose mention offers no folder affordance. `openFile('.')` would
+      // name a directory, which a text preview correctly refuses. The only
+      // folder action on the page is the changed-files card's header, and only
+      // when the Host has a desktop.
+      const folders = page.getByRole('button', { name: /folder/i })
+      expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count())
 
       // Split, then dock-drag: the kit's gestures drive the store's actions.
       await panes.first().locator('[data-dockkit-split-button]').click()
@@ -818,7 +829,7 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = fx.locator('[data-rightbar-col]')
         await ensureExpanded(fx, column)
         await width(column)
-        await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+        await proseChip(fx).click()
         await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 })
         const wrap = column.locator('[data-textpreview-tool="wrap"]')
         expect(await wrap.getAttribute('aria-pressed')).toBe('true')
@@ -887,7 +898,7 @@ describe('web e2e: shipped right Sidebar', () => {
       //    leave it standing, since a pane emptied by a move is dropped.
       const first = panes.first()
       const strip = first.locator('[data-dockkit-strip]')
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME])
       const order = await tabTitles(first)
       // The insertion index is measured against chip midpoints, not strip width.
@@ -961,7 +972,7 @@ describe('web e2e: shipped right Sidebar', () => {
       const column = await resetSidebar(page)
       const panes = column.locator('[data-dockkit-pane]')
       expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
       await panes.first().locator('[data-dockkit-split-button]').click()
       await expect.poll(async () => await panes.count()).toBe(2)
@@ -997,7 +1008,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // sample file, close the guide (an ordinary close with two tabs), then
       // close the file: the column collapses in the same gesture, and the
       // settle rule reseeds the current default, so reopening shows Start.
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await proseChip(page).click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab-close]').first().click()

+ 1 - 1
apps/web/tsconfig.json

@@ -109,7 +109,7 @@
     "tests/sidebar-right.e2e.ts",
     "tests/sidebar-terminal.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
-    "tests/produced-files.e2e.ts",
+    "tests/changed-files-turn.e2e.ts",
     "tests/produced-file-mentions.e2e.ts",
     "tests/goal-bar.e2e.ts",
     "tests/goal-command-presentation.e2e.ts",

+ 2 - 2
docs/capability-seams.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 docs/capability-seams.md
-capability-seams.md: b40adeafe7cec40dfbbe6a65788e5888097ef57f
-capability-seams.zh.md: b33986f205c2e1352343c7c5ad3700efb027eb61
+capability-seams.md: a6a90b1fd72278a3027385b3b4cd9a3c78cbb903
+capability-seams.zh.md: d906ac7d7a9617a4044f7b9960eca947cc1ce8a9

+ 4 - 0
docs/capability-seams.md

@@ -65,6 +65,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_workspace_changes["workspace-changes"]
+  svc_workspaceChanges["ctx.workspaceChanges<br/>Host per-turn changed-file summaries"]
   pkg_api_terminal_controller["api-terminal-controller"]
   svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
@@ -381,6 +383,7 @@ flowchart LR
   pkg_workflow --> svc_workflowEngine
   pkg_workflow_ptc --> svc_workflowEngine
   pkg_workspace --> svc_workspaceRegistry
+  pkg_workspace_changes --> svc_workspaceChanges
   svc_agentDefaultModel --> pkg_api_session_controller
   svc_agentDefaultModel --> pkg_headless
   svc_agentLoop --> pkg_base
@@ -543,6 +546,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
+| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
 | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |

+ 4 - 0
docs/capability-seams.zh.md

@@ -67,6 +67,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_workspace_changes["workspace-changes"]
+  svc_workspaceChanges["ctx.workspaceChanges<br/>Host per-turn changed-file summaries"]
   pkg_api_terminal_controller["api-terminal-controller"]
   svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
@@ -383,6 +385,7 @@ flowchart LR
   pkg_workflow --> svc_workflowEngine
   pkg_workflow_ptc --> svc_workflowEngine
   pkg_workspace --> svc_workspaceRegistry
+  pkg_workspace_changes --> svc_workspaceChanges
   svc_agentDefaultModel --> pkg_api_session_controller
   svc_agentDefaultModel --> pkg_headless
   svc_agentLoop --> pkg_base
@@ -545,6 +548,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
+| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
 | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: e6266355682b8373d31afd0306313865df797e80
-config-catalog.zh.md: 3193cce300ae7abb7b8564c0507e2107760ac105
+config-catalog.md: 9d82a1a2c1d9d954ea3f1ca4463b758b669d78da
+config-catalog.zh.md: 2492ade95fa8b886b92f235db8abe12a0fac7ddf

+ 22 - 2
docs/config-catalog.md

@@ -3102,7 +3102,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
+Source: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts)
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
@@ -3649,6 +3649,26 @@ export interface Config {
 
 Source: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/workflow-ptc/src/index.ts)
 
+<a id="deepseek-aidsh-workspace-changes"></a>
+
+## `@deepseek-ai/dsh-workspace-changes`
+
+Requires: `subprocess`
+
+```ts config-catalog
+/** Snapshot bounds. Invalid values fail plugin load. */
+export interface Config {
+  /** Milliseconds one git command may run before the turn's record is abandoned. */
+  timeoutMs: number
+  /** Bytes of git output retained per command; a larger diff listing abandons the record. */
+  outputMaxBytes: number
+  /** Maximum files carried by one summary; `total` still reports the complete count. */
+  maxFiles: number
+}
+```
+
+Source: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
+
 ## Loadable plugins with no config
 
 These load from a `cordis.yml` entry with no `config:` block; they declare no configuration API.
@@ -3671,7 +3691,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 21 - 1
docs/config-catalog.zh.md

@@ -3104,7 +3104,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
+来源:[`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts)
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
@@ -3651,6 +3651,26 @@ export interface Config {
 
 来源: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/workflow-ptc/src/index.ts)
 
+<a id="deepseek-aidsh-workspace-changes"></a>
+
+## `@deepseek-ai/dsh-workspace-changes`
+
+依赖: `subprocess`
+
+```ts config-catalog
+/** Snapshot bounds. Invalid values fail plugin load. */
+export interface Config {
+  /** Milliseconds one git command may run before the turn's record is abandoned. */
+  timeoutMs: number
+  /** Bytes of git output retained per command; a larger diff listing abandons the record. */
+  outputMaxBytes: number
+  /** Maximum files carried by one summary; `total` still reports the complete count. */
+  maxFiles: number
+}
+```
+
+来源: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
+
 ## 无配置的可加载插件
 
 这些插件通过 `cordis.yml` 中不含 `config:` 块的条目加载;它们未声明任何配置接口。

+ 2 - 2
docs/event-producer-consumer.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 docs/event-producer-consumer.md
-event-producer-consumer.md: 29db715946dd2fdc75746f05832f76097ec4c34e
-event-producer-consumer.zh.md: 87160f0c242dd7a3eec9a9c4c40b119f9bbdb438
+event-producer-consumer.md: 397ad7a77e49ef8da8bc3a43171a494eb42917fe
+event-producer-consumer.zh.md: d5768b0842c0e821173e3d80535988f91454114f

+ 5 - 5
docs/event-producer-consumer.md

@@ -20,7 +20,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -55,8 +55,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
@@ -70,9 +70,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 5 - 5
docs/event-producer-consumer.zh.md

@@ -22,7 +22,7 @@
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -57,8 +57,8 @@
 | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
@@ -72,9 +72,9 @@
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: 8c2d762371f461e5821e78160dc913139044b95e
-module-graph.zh.md: 59ffeda973722b388b04067cf3341c3c7955ad86
+module-graph.md: b17729e48e1415cd3eb15d2f84f3cb5df54c4c29
+module-graph.zh.md: bcfa6291e4968d000a47ee55d3713fb23705726f

+ 16 - 8
docs/module-graph.md

@@ -57,7 +57,6 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
-    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -221,6 +220,10 @@ flowchart TD
     pkg_credentials["credentials"]
     pkg_credentials_local["credentials-local"]
   end
+  subgraph group_deliverables["packages/deliverables"]
+    pkg_tool_present["tool-present"]
+    pkg_workspace_changes["workspace-changes"]
+  end
   subgraph group_experimental["packages/experimental"]
     pkg_experimental_agent_team["experimental-agent-team"]
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
@@ -762,12 +765,6 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
-  pkg_tool_present --> pkg_agent
-  pkg_tool_present --> pkg_fs
-  pkg_tool_present --> pkg_llm
-  pkg_tool_present --> pkg_session
-  pkg_tool_present --> pkg_session_projection
-  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -822,6 +819,16 @@ flowchart TD
   pkg_file_reference_local --> pkg_file_reference
   pkg_file_reference_local --> pkg_system_prompt
   pkg_file_reference_local --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
+  pkg_workspace_changes --> pkg_agent
+  pkg_workspace_changes --> pkg_session
+  pkg_workspace_changes --> pkg_subprocess
+  pkg_workspace_changes --> pkg_tools
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt
@@ -1491,7 +1498,6 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
@@ -1503,6 +1509,8 @@ flowchart TD
 | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
+| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 16 - 8
docs/module-graph.zh.md

@@ -59,7 +59,6 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
-    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -223,6 +222,10 @@ flowchart TD
     pkg_credentials["credentials"]
     pkg_credentials_local["credentials-local"]
   end
+  subgraph group_deliverables["packages/deliverables"]
+    pkg_tool_present["tool-present"]
+    pkg_workspace_changes["workspace-changes"]
+  end
   subgraph group_experimental["packages/experimental"]
     pkg_experimental_agent_team["experimental-agent-team"]
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
@@ -764,12 +767,6 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
-  pkg_tool_present --> pkg_agent
-  pkg_tool_present --> pkg_fs
-  pkg_tool_present --> pkg_llm
-  pkg_tool_present --> pkg_session
-  pkg_tool_present --> pkg_session_projection
-  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -824,6 +821,16 @@ flowchart TD
   pkg_file_reference_local --> pkg_file_reference
   pkg_file_reference_local --> pkg_system_prompt
   pkg_file_reference_local --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
+  pkg_workspace_changes --> pkg_agent
+  pkg_workspace_changes --> pkg_session
+  pkg_workspace_changes --> pkg_subprocess
+  pkg_workspace_changes --> pkg_tools
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use
   pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt
@@ -1493,7 +1500,6 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
@@ -1505,6 +1511,8 @@ flowchart TD
 | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
+| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: e5aef0390d3529ff6cd6c2faf16bc68ea0d1dc0e
-persistence-catalog.zh.md: 31e385e165324a9b3d9962e0d6b448ce9bb3fc5a
+persistence-catalog.md: 637dcfcf48d2699b0072d087c7a765fe900a7684
+persistence-catalog.zh.md: e8af53ae630ac7b061e9d158eef8f26d2a5fdde4

+ 58 - 18
docs/persistence-catalog.md

@@ -77,6 +77,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
 | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
 | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
 | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
+| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
 
 ## Event envelope
 
@@ -476,9 +477,9 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio
 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
 ```
 
-Types: [ToolCallId](subsystems/core.md)
+Types: [PresentedFile](subsystems/deliverables.md) · [ToolCallId](subsystems/core.md)
 
-Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts)
 
 ### `feedback/*`
 
@@ -1240,6 +1241,23 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/
 
 Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/web-search-deepseek/src/provider.ts)
 
+### `workspace/*`
+
+<a id="workspacechanges--log-only"></a>
+
+#### `workspace/changes` — log-only
+
+```ts persistence-catalog
+/**
+ * A completed top-level turn's changed files were summarized; the summary itself stays on the
+ * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
+ * lives. The latest event for one turn replaces earlier ones.
+ */
+'workspace/changes': { turn: number }
+```
+
+Source: [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
+
 ## Resolved persistence types
 
 Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.
@@ -2627,7 +2645,7 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
 
 SHA-256: `1528539c63db8b23506f0209a99ce77d8ad138adfbfcee3d4769b7382d93756c`
 
-Sources: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+Sources: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2641,7 +2659,7 @@ Sources: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-presen
 
 SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345`
 
-Array of [`packages/fs/tool-present/src/types.ts#PresentedFile`](#persistence-type-packagesfstool-presentsrctypestspresentedfile).
+Array of [`packages/deliverables/tool-present/src/types.ts#PresentedFile`](#persistence-type-packagesdeliverablestool-presentsrctypestspresentedfile).
 
 <a id="persistence-type-eventfeedbackmessage-delete"></a>
 
@@ -3902,7 +3920,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
 
 SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
 
-Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -4023,6 +4041,20 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:71`](../packages/web
 | `name` | required | `"web_search"` |
 | `type` | required | `"web_search_20250305"` |
 
+<a id="persistence-type-eventworkspacechanges"></a>
+
+### `event:workspace/changes`
+
+SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72`
+
+| Property | Presence | Type |
+|---|---|---|
+| `data` | required | [`event:turn/start.data`](#persistence-type-eventturnstartdata) |
+| `ignorable` | optional | `true` |
+| `seq` | required | `number` |
+| `time` | required | `number` |
+| `type` | required | `"workspace/changes"` |
+
 <a id="persistence-type-every"></a>
 
 ### `"every"`
@@ -4670,6 +4702,19 @@ Sources: [`packages/core/tools/src/types.ts:11`](../packages/core/tools/src/type
 | `rootCallId` | required | `string` |
 | `subCallId` | required | `string` |
 
+<a id="persistence-type-packagesdeliverablestool-presentsrctypestspresentedfile"></a>
+
+### `packages/deliverables/tool-present/src/types.ts#PresentedFile`
+
+SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97`
+
+Sources: [`packages/deliverables/tool-present/src/types.ts:5`](../packages/deliverables/tool-present/src/types.ts)
+
+| Property | Presence | Type |
+|---|---|---|
+| `description` | optional | `string` |
+| `path` | required | `string` |
+
 <a id="persistence-type-packagesexperimentalagent-teamsrctypeststeammemberphase"></a>
 
 ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase`
@@ -4838,19 +4883,6 @@ One of:
 - `"negative"`
 - `"positive"`
 
-<a id="persistence-type-packagesfstool-presentsrctypestspresentedfile"></a>
-
-### `packages/fs/tool-present/src/types.ts#PresentedFile`
-
-SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97`
-
-Sources: [`packages/fs/tool-present/src/types.ts:5`](../packages/fs/tool-present/src/types.ts)
-
-| Property | Presence | Type |
-|---|---|---|
-| `description` | optional | `string` |
-| `path` | required | `string` |
-
 <a id="persistence-type-packagesgoalgoalsrcdomaintsgoalchangemeta"></a>
 
 ### `packages/goal/goal/src/domain.ts#GoalChangeMeta`
@@ -6668,3 +6700,11 @@ SHA-256: `db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0`
 SHA-256: `a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa`
 
 `"workspace-write"`
+
+<a id="persistence-type-workspacechanges"></a>
+
+### `"workspace/changes"`
+
+SHA-256: `8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d`
+
+`"workspace/changes"`

+ 58 - 18
docs/persistence-catalog.zh.md

@@ -79,6 +79,7 @@
 | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
 | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
 | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
+| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
 
 ## 事件信封
 
@@ -478,9 +479,9 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
 ```
 
-类型:[ToolCallId](subsystems/core.zh.md)
+类型:[PresentedFile](subsystems/deliverables.zh.md) · [ToolCallId](subsystems/core.zh.md)
 
-来源:[`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+来源:[`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts)
 
 ### `feedback/*`
 
@@ -1242,6 +1243,23 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 来源:[`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/web-search-deepseek/src/provider.ts)
 
+### `workspace/*`
+
+<a id="workspacechanges--log-only"></a>
+
+#### `workspace/changes` — log-only
+
+```ts persistence-catalog
+/**
+ * A completed top-level turn's changed files were summarized; the summary itself stays on the
+ * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
+ * lives. The latest event for one turn replaces earlier ones.
+ */
+'workspace/changes': { turn: number }
+```
+
+来源:[`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
+
 ## 已解析的持久化类型
 
 每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。
@@ -2629,7 +2647,7 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
 
 SHA-256: `1528539c63db8b23506f0209a99ce77d8ad138adfbfcee3d4769b7382d93756c`
 
-来源:[`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+来源:[`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2643,7 +2661,7 @@ SHA-256: `1528539c63db8b23506f0209a99ce77d8ad138adfbfcee3d4769b7382d93756c`
 
 SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345`
 
-[`packages/fs/tool-present/src/types.ts#PresentedFile`](#persistence-type-packagesfstool-presentsrctypestspresentedfile) 的数组。
+[`packages/deliverables/tool-present/src/types.ts#PresentedFile`](#persistence-type-packagesdeliverablestool-presentsrctypestspresentedfile) 的数组。
 
 <a id="persistence-type-eventfeedbackmessage-delete"></a>
 
@@ -3904,7 +3922,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
 
 SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
 
-来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -4025,6 +4043,20 @@ SHA-256: `2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3`
 | `name` | 必需 | `"web_search"` |
 | `type` | 必需 | `"web_search_20250305"` |
 
+<a id="persistence-type-eventworkspacechanges"></a>
+
+### `event:workspace/changes`
+
+SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72`
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `data` | 必需 | [`event:turn/start.data`](#persistence-type-eventturnstartdata) |
+| `ignorable` | 可选 | `true` |
+| `seq` | 必需 | `number` |
+| `time` | 必需 | `number` |
+| `type` | 必需 | `"workspace/changes"` |
+
 <a id="persistence-type-every"></a>
 
 ### `"every"`
@@ -4672,6 +4704,19 @@ SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f`
 | `rootCallId` | 必需 | `string` |
 | `subCallId` | 必需 | `string` |
 
+<a id="persistence-type-packagesdeliverablestool-presentsrctypestspresentedfile"></a>
+
+### `packages/deliverables/tool-present/src/types.ts#PresentedFile`
+
+SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97`
+
+来源:[`packages/deliverables/tool-present/src/types.ts:5`](../packages/deliverables/tool-present/src/types.ts)
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `description` | 可选 | `string` |
+| `path` | 必需 | `string` |
+
 <a id="persistence-type-packagesexperimentalagent-teamsrctypeststeammemberphase"></a>
 
 ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase`
@@ -4840,19 +4885,6 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2`
 - `"negative"`
 - `"positive"`
 
-<a id="persistence-type-packagesfstool-presentsrctypestspresentedfile"></a>
-
-### `packages/fs/tool-present/src/types.ts#PresentedFile`
-
-SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97`
-
-来源:[`packages/fs/tool-present/src/types.ts:5`](../packages/fs/tool-present/src/types.ts)
-
-| 属性 | 存在性 | 类型 |
-|---|---|---|
-| `description` | 可选 | `string` |
-| `path` | 必需 | `string` |
-
 <a id="persistence-type-packagesgoalgoalsrcdomaintsgoalchangemeta"></a>
 
 ### `packages/goal/goal/src/domain.ts#GoalChangeMeta`
@@ -6670,3 +6702,11 @@ SHA-256: `db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0`
 SHA-256: `a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa`
 
 `"workspace-write"`
+
+<a id="persistence-type-workspacechanges"></a>
+
+### `"workspace/changes"`
+
+SHA-256: `8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d`
+
+`"workspace/changes"`

+ 6 - 0
docs/persistence-changes/2026-09-14-workspace-changes-event.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 docs/persistence-changes/2026-09-14-workspace-changes-event.md
+2026-09-14-workspace-changes-event.md: 5d8a4ff6526dc67444d5dce0076b005d96b3b059
+2026-09-14-workspace-changes-event.zh.md: fa2d037dc50dae2bfb7ba4b3801558559b73fd7c

+ 48 - 0
docs/persistence-changes/2026-09-14-workspace-changes-event.md

@@ -0,0 +1,48 @@
+---
+description: "Records a persistence type transition and its compatibility acknowledgement."
+kind: persistence-change
+---
+
+# 2026-09-14-workspace-changes-event
+
+English | [中文](2026-09-14-workspace-changes-event.zh.md)
+
+## Summary
+
+Adds the log-only workspace/changes event that records the files a top-level turn changed.
+
+## Table of Contents
+
+- [Declaration](#declaration)
+- [Compatibility](#compatibility)
+- [Verification](#verification)
+- [Dev Note](#dev-note)
+
+<a id="declaration"></a>
+## Declaration
+
+```yaml persistence-change
+schemaVersion: 1
+id: 2026-09-14-workspace-changes-event
+baseline: false
+changes:
+  - root: "event:workspace/changes"
+    previous: null
+    after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"
+    decision: same-version
+```
+
+<a id="compatibility"></a>
+## Compatibility
+
+A new root in the same Session format version. Existing logs contain no such event and stay valid; readers that predate it refuse a log carrying it, as every required-on-read event does. The event is appended only by the Web bundle's workspace-changes plugin and is never model-visible; the Web changed-files card is its only consumer and reads the latest event per turn.
+
+<a id="verification"></a>
+## Verification
+
+pnpm exec vitest run packages/deliverables/workspace-changes packages/client/ui-deliverables: 165 tests passed; snapshots/web/changed-files-turn replays the recorded event through the Web profile.
+
+<a id="dev-note"></a>
+## Dev Note
+
+None.

+ 72 - 0
docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json

@@ -0,0 +1,72 @@
+{
+  "formatVersion": 1,
+  "roots": [
+    {
+      "key": "event:workspace/changes",
+      "kind": "event",
+      "event": "workspace/changes",
+      "surface": false,
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
+      "schema": {
+        "root": 0,
+        "nodes": [
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "data",
+                "type": 1,
+                "optional": false
+              },
+              {
+                "name": "ignorable",
+                "type": 3,
+                "optional": true
+              },
+              {
+                "name": "seq",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "time",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "type",
+                "type": 4,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "turn",
+                "type": 2,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "primitive",
+            "type": "number"
+          },
+          {
+            "kind": "literal",
+            "value": true
+          },
+          {
+            "kind": "literal",
+            "value": "workspace/changes"
+          }
+        ]
+      }
+    }
+  ],
+  "types": []
+}

+ 48 - 0
docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md

@@ -0,0 +1,48 @@
+---
+description: "记录持久化类型更改及其兼容性确认。"
+kind: persistence-change
+---
+
+# 2026-09-14-workspace-changes-event
+
+[English](2026-09-14-workspace-changes-event.md) | 中文
+
+## 概述
+
+新增仅写日志的 workspace/changes 事件,记录顶层轮次改动的文件。
+
+## 目录
+
+- [声明](#declaration)
+- [兼容性](#compatibility)
+- [验证](#verification)
+- [开发备注](#dev-note)
+
+<a id="declaration"></a>
+## 声明
+
+```yaml persistence-change
+schemaVersion: 1
+id: 2026-09-14-workspace-changes-event
+baseline: false
+changes:
+  - root: "event:workspace/changes"
+    previous: null
+    after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"
+    decision: same-version
+```
+
+<a id="compatibility"></a>
+## 兼容性
+
+同一 Session 格式版本内的新根类型。已有日志没有该事件,保持有效;早于它的读取方遇到它会拒绝该日志,与所有读取时必需的事件一致。该事件只由 Web bundle 的 workspace-changes 插件追加,模型永远看不到;Web 的改动文件卡片是唯一消费者,读取每轮最新的一条。
+
+<a id="verification"></a>
+## 验证
+
+pnpm exec vitest run packages/deliverables/workspace-changes packages/client/ui-deliverables:165 个测试通过;snapshots/web/changed-files-turn 通过 Web profile 回放了记录的事件。
+
+<a id="dev-note"></a>
+## 开发备注
+
+无。

+ 2 - 2
docs/persistence-changes/historical-formats/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md
-README.md: 35fa4cec9c960e8d0e047c42e35dad83992ac8d0
-README.zh.md: 7dd2af55d1d68c108d678e6e0849f7d679da929d
+README.md: fda6ac3b4fc3e52ef215dc0a2fca00576cd4ef0c
+README.zh.md: 792f47e532d6ebfdbb745c1bc2703c7b5f4fcc17

+ 1 - 1
docs/persistence-changes/historical-formats/README.md

@@ -31,7 +31,7 @@ The index is generated from validated snapshots and the current writer constant.
 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 |
 | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
 | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
-| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
+| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 470 |
 
 <!-- persistence-format-index:end -->
 

+ 1 - 1
docs/persistence-changes/historical-formats/README.zh.md

@@ -31,7 +31,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 |
 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
 | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
-| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
+| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 470 |
 
 <!-- persistence-format-index:end -->
 

+ 3 - 3
docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.schema.json

@@ -794,7 +794,7 @@
         "event:deliverables/presented.data"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts"
+        "packages/deliverables/tool-present/src/types.ts"
       ]
     },
     {
@@ -2024,10 +2024,10 @@
         ]
       },
       "names": [
-        "packages/fs/tool-present/src/types.ts#PresentedFile"
+        "packages/deliverables/tool-present/src/types.ts#PresentedFile"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts"
+        "packages/deliverables/tool-present/src/types.ts"
       ]
     },
     {

+ 154 - 5
docs/persistence-schema.json

@@ -13742,6 +13742,72 @@
           }
         ]
       }
+    },
+    {
+      "key": "event:workspace/changes",
+      "kind": "event",
+      "event": "workspace/changes",
+      "surface": false,
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
+      "schema": {
+        "root": 0,
+        "nodes": [
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "data",
+                "type": 1,
+                "optional": false
+              },
+              {
+                "name": "ignorable",
+                "type": 3,
+                "optional": true
+              },
+              {
+                "name": "seq",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "time",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "type",
+                "type": 4,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "turn",
+                "type": 2,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "primitive",
+            "type": "number"
+          },
+          {
+            "kind": "literal",
+            "value": true
+          },
+          {
+            "kind": "literal",
+            "value": "workspace/changes"
+          }
+        ]
+      }
     }
   ],
   "types": [
@@ -17933,7 +17999,7 @@
         "event:deliverables/presented.data"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts:15"
+        "packages/deliverables/tool-present/src/types.ts:15"
       ]
     },
     {
@@ -43247,6 +43313,20 @@
         "packages/llm/llm/src/types.ts:147"
       ]
     },
+    {
+      "digest": "8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d",
+      "schema": {
+        "root": 0,
+        "nodes": [
+          {
+            "kind": "literal",
+            "value": "workspace/changes"
+          }
+        ]
+      },
+      "names": [],
+      "sources": []
+    },
     {
       "digest": "8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af",
       "schema": {
@@ -50992,10 +51072,10 @@
         ]
       },
       "names": [
-        "packages/fs/tool-present/src/types.ts#PresentedFile"
+        "packages/deliverables/tool-present/src/types.ts#PresentedFile"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts:5"
+        "packages/deliverables/tool-present/src/types.ts:5"
       ]
     },
     {
@@ -58349,6 +58429,72 @@
       "names": [],
       "sources": []
     },
+    {
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
+      "schema": {
+        "root": 0,
+        "nodes": [
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "data",
+                "type": 1,
+                "optional": false
+              },
+              {
+                "name": "ignorable",
+                "type": 3,
+                "optional": true
+              },
+              {
+                "name": "seq",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "time",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "type",
+                "type": 4,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "turn",
+                "type": 2,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "primitive",
+            "type": "number"
+          },
+          {
+            "kind": "literal",
+            "value": true
+          },
+          {
+            "kind": "literal",
+            "value": "workspace/changes"
+          }
+        ]
+      },
+      "names": [
+        "event:workspace/changes"
+      ],
+      "sources": []
+    },
     {
       "digest": "e31572180d6b28b73f073ad30ae19d5d82982d3daf984723984e23da1e7c459a",
       "schema": {
@@ -58458,6 +58604,7 @@
         "SessionEventEnvelope[53]",
         "SessionEventEnvelope[54]",
         "SessionEventEnvelope[56]",
+        "SessionEventEnvelope[57]",
         "SessionEventEnvelope[5]",
         "SessionEventEnvelope[7]",
         "SessionEventEnvelope[8]",
@@ -60546,10 +60693,12 @@
         ]
       },
       "names": [
-        "event:turn/start.data"
+        "event:turn/start.data",
+        "event:workspace/changes.data"
       ],
       "sources": [
-        "packages/core/session/src/types.ts:276"
+        "packages/core/session/src/types.ts:276",
+        "packages/deliverables/workspace-changes/src/types.ts:58"
       ]
     },
     {

+ 2 - 2
docs/subsystems/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/README.md
-README.md: 01b9620c8e59793bd22cb484dc1c6e40296417c1
-README.zh.md: 6d4cbc8e4f104870d52257bc815d656ce5c29b6c
+README.md: b4d6aa226979525ef658f9cf1d33d5ca3e59dcf6
+README.zh.md: 9134fcf37789911aec05dcdc9e9c3dc036a33873

+ 1 - 0
docs/subsystems/README.md

@@ -15,6 +15,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
 | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution |
 | [schedule.md](schedule.md) | Session-local reminder records, durable transitions, active views, and ordinary-conversation delivery |
 | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant |
+| [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the Host-served `WorkspaceChangesSummary` of changed files from git snapshots |
 | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views |
 | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
 | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` |

+ 1 - 0
docs/subsystems/README.zh.md

@@ -15,6 +15,7 @@
 | [goal.md](goal.zh.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 |
 | [schedule.md](schedule.zh.md) | 仅限会话内的提醒记录、持久转换、活动视图与普通对话交付 |
 | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和未结束轮次不变式 |
+| [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出、由 Host 提供的 `WorkspaceChangesSummary` 改动摘要 |
 | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 |
 | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
 | [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` |

+ 6 - 0
docs/subsystems/deliverables.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 docs/subsystems/deliverables.md
+deliverables.md: cf795d747576451aafb104aad930efc352000fd3
+deliverables.zh.md: e4bb8bb2e590982d714701f9ab0b1851732831d2

+ 111 - 0
docs/subsystems/deliverables.md

@@ -0,0 +1,111 @@
+# Deliverables
+
+English | [中文](deliverables.zh.md)
+
+What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end (or, outside a repository, from the file tools' persisted hunks alone), announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md).
+
+Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
+
+## `PresentedFile` — one declared delivery
+
+```ts type-equiv
+/** A declared filesystem file whose current contents remain at its source path. */
+interface PresentedFile {
+  /** Original absolute path or path relative to the Session working directory. */
+  path: string
+  /** Optional description supplied by the model. */
+  description?: string
+}
+```
+
+## `WorkspaceChangedFile` — one changed file
+
+```ts type-equiv
+/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+interface WorkspaceChangedFile {
+  /** Path relative to the Session working directory, or an absolute Host path outside it. */
+  path: string
+  /**
+   * Sort key and label: the relative path inside the working directory, a
+   * `../` path for repository files above it, a `~` path under the home
+   * directory, otherwise the absolute path. Always slash-separated.
+   */
+  display: string
+  /** Lines added; zero for a binary file. */
+  added: number
+  /** Lines deleted; zero for a binary file. */
+  deleted: number
+  /** Present when git reported the file as binary. */
+  binary?: true
+}
+```
+
+## `WorkspaceChangesSummary` — one turn's change summary
+
+```ts type-equiv
+/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */
+interface WorkspaceChangesSummary {
+  /** The turn whose file changes this summary describes. */
+  turn: number
+  /** The Session working directory `path` values are relative to. */
+  cwd: string
+  /** Changed files in `display` order, capped at the plugin's `maxFiles`. */
+  files: WorkspaceChangedFile[]
+  /** Complete changed-file count, including files omitted by the cap. */
+  total: number
+  /** Lines added over every changed file, including files omitted by the cap. */
+  added: number
+  /** Lines deleted over every changed file, including files omitted by the cap. */
+  deleted: number
+  /** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */
+  snapshot?: { before: string; after: string }
+}
+```
+
+## `WorkspaceChanges` — the Host service serving summaries
+
+```ts type-equiv
+/** Serves the summaries the recorder keeps for live Sessions. */
+interface WorkspaceChanges {
+  /**
+   * The summary announced by one `workspace/changes` event.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
+   */
+  summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+}
+```
+
+## Durable events and the served summary
+
+`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model.
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxworkspacechanges--workspacechanges"></a>
+
+### `ctx.workspaceChanges` — `WorkspaceChanges`
+
+Serves the summaries the recorder keeps for live Sessions.
+
+```ts cordis-catalog
+/**
+ * The summary announced by one `workspace/changes` event.
+ * @param sessionId - the Session that appended the event.
+ * @param seq - the event's sequence number.
+ * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
+ */
+summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+```
+
+Types: [SessionId](core.md)
+
+Source: [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
+<!-- END GENERATED cordis-surface -->

+ 111 - 0
docs/subsystems/deliverables.zh.md

@@ -0,0 +1,111 @@
+# 产出物
+
+[English](deliverables.md) | 中文
+
+记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出(不在仓库内时只由文件工具持久化的 hunk 得出),用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。
+
+源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
+
+## `PresentedFile`:一条声明的交付
+
+```ts type-equiv
+/** A declared filesystem file whose current contents remain at its source path. */
+interface PresentedFile {
+  /** Original absolute path or path relative to the Session working directory. */
+  path: string
+  /** Optional description supplied by the model. */
+  description?: string
+}
+```
+
+## `WorkspaceChangedFile`:一个改动的文件
+
+```ts type-equiv
+/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+interface WorkspaceChangedFile {
+  /** Path relative to the Session working directory, or an absolute Host path outside it. */
+  path: string
+  /**
+   * Sort key and label: the relative path inside the working directory, a
+   * `../` path for repository files above it, a `~` path under the home
+   * directory, otherwise the absolute path. Always slash-separated.
+   */
+  display: string
+  /** Lines added; zero for a binary file. */
+  added: number
+  /** Lines deleted; zero for a binary file. */
+  deleted: number
+  /** Present when git reported the file as binary. */
+  binary?: true
+}
+```
+
+## `WorkspaceChangesSummary`:一轮的改动摘要
+
+```ts type-equiv
+/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */
+interface WorkspaceChangesSummary {
+  /** The turn whose file changes this summary describes. */
+  turn: number
+  /** The Session working directory `path` values are relative to. */
+  cwd: string
+  /** Changed files in `display` order, capped at the plugin's `maxFiles`. */
+  files: WorkspaceChangedFile[]
+  /** Complete changed-file count, including files omitted by the cap. */
+  total: number
+  /** Lines added over every changed file, including files omitted by the cap. */
+  added: number
+  /** Lines deleted over every changed file, including files omitted by the cap. */
+  deleted: number
+  /** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */
+  snapshot?: { before: string; after: string }
+}
+```
+
+## `WorkspaceChanges`:提供摘要的 Host 服务
+
+```ts type-equiv
+/** Serves the summaries the recorder keeps for live Sessions. */
+interface WorkspaceChanges {
+  /**
+   * The summary announced by one `workspace/changes` event.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
+   */
+  summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+}
+```
+
+## 持久事件与提供的摘要
+
+`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxworkspacechanges--workspacechanges"></a>
+
+### `ctx.workspaceChanges` — `WorkspaceChanges`
+
+Serves the summaries the recorder keeps for live Sessions.
+
+```ts cordis-catalog
+/**
+ * The summary announced by one `workspace/changes` event.
+ * @param sessionId - the Session that appended the event.
+ * @param seq - the event's sequence number.
+ * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
+ */
+summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+```
+
+Types: [SessionId](core.zh.md)
+
+Source: [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
+<!-- END GENERATED cordis-surface -->

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: 6a65943dbeaf300e3256e9477715f4cf44147c13
-tool-catalog.zh.md: 4f868b0c90b3a6b7f8e2ad854bcf166ec479e064
+tool-catalog.md: eae53047f91a2a86fd9d5e68ff2f84d8cf62faa6
+tool-catalog.zh.md: 15dda3b508e78dc3eeaddabed05b60f318886064

+ 1 - 1
docs/tool-catalog.md

@@ -660,7 +660,7 @@ Declare existing files accessible through the Session filesystem as final delive
 }
 ```
 
-Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
+Source: [`packages/deliverables/tool-present/src/index.ts`](../packages/deliverables/tool-present/src/index.ts)
 
 Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.
 

+ 1 - 1
docs/tool-catalog.zh.md

@@ -664,7 +664,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 }
 ```
 
-来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
+来源: [`packages/deliverables/tool-present/src/index.ts`](../packages/deliverables/tool-present/src/index.ts)
 
 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。
 

+ 2 - 2
packages/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/README.md
-README.md: 4fc579f7c58434f13425b933f93f4e96ba513f2e
-README.zh.md: 0e78e7ae7567436fa85183a0373ccef9221359ec
+README.md: 113d5d11a0b108b84c0b8983602b7d99c0f79425
+README.zh.md: b86f98cc4c6ad8f748d3bd31d9a4bcaa7a10f27e

+ 1 - 0
packages/README.md

@@ -44,6 +44,7 @@ Every package lives in exactly one group; new packages join existing groups, and
 | [`computer-use/`](computer-use/README.md) | Exclusive named desktop-provider registration |
 | [`browser-use/`](browser-use/README.md) | Exclusive named browser-provider registration |
 | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends |
+| [`deliverables/`](deliverables/README.md) | Turn deliverables: explicit file delivery and recorded workspace changes |
 | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, discovery tools |
 | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool |
 | [`skill/`](skill/README.md) | Skill capability family: provider registry, local provider, model-facing catalog/loader |

+ 1 - 0
packages/README.zh.md

@@ -44,6 +44,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
 | [`computer-use/`](computer-use/README.zh.md) | 按名称独占注册桌面提供方 |
 | [`browser-use/`](browser-use/README.zh.md) | 按名称独占注册浏览器提供方 |
 | [`sandbox/`](sandbox/README.zh.md) | 进程限制 seam;bwrap、Landlock、Seatbelt 后端 |
+| [`deliverables/`](deliverables/README.zh.md) | 轮次交付物:显式文件交付与记录的工作区改动 |
 | [`fs/`](fs/README.zh.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、发现工具 |
 | [`lsp/`](lsp/README.zh.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 |
 | [`skill/`](skill/README.zh.md) | skill(技能)能力系列:提供方注册表、本地提供方、面向模型的目录/loader |

+ 8 - 2
packages/bundle/web-app/cordis.patch.yml

@@ -287,11 +287,17 @@
     - id: ui-workflow-run
       name: '@deepseek-ai/dsh-client-ui-workflow-run'
 
-    # Turn tail: the produced-files row under each closing assistant message.
-    # Remove this entry to turn the surface off; the tail hole renders empty.
+    # Turn tail: the changed-files card and delivery cards under each closing
+    # assistant message. Remove this entry to turn the surface off; the tail
+    # hole renders empty.
     - id: ui-deliverables
       name: '@deepseek-ai/dsh-client-ui-deliverables'
 
+    # Records each top-level turn's changed files from git working-tree
+    # snapshots; the changed-files card above renders its events.
+    - id: workspace-changes
+      name: '@deepseek-ai/dsh-workspace-changes'
+
 
     - id: ui-workspace
       name: '@deepseek-ai/dsh-client-ui-workspace'

+ 1 - 0
packages/bundle/web-app/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
     "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
+    "@deepseek-ai/dsh-workspace-changes": "workspace:^",
     "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
     "@deepseek-ai/dsh-client-ui-goal": "workspace:^",

+ 2 - 2
packages/client/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: 6df95c24c8e9a70f8beb0e1cdd066c9f0facae2b
-README.zh.md: 12024690b09e4a013dae3b6dcffa58e2040d9251
+README.md: 171653b926b2aa4da906955c079e4abf0dff3fa1
+README.zh.md: 9081ffb98761cc6ecc38a66db6fbbf5e89444f51

+ 1 - 1
packages/client/README.md

@@ -74,7 +74,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
-| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
+| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card, delivery cards, and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — |

+ 1 - 1
packages/client/README.zh.md

@@ -74,7 +74,7 @@ kind: "package-group"
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
-| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
+| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片、交付文件卡片与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — |

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: 5ae62206bed36e611635511f0ad3472ea4e98107
-README.zh.md: 3d154d9c4239f119624c74e219980c0be3ab81c0
+README.md: 4547d0887b069ed40686af8821214d96b28f6c00
+README.zh.md: 8d3534b9fc2b72d3308b10b62f7536a20e8c90f1

+ 17 - 13
packages/client/ui-deliverables/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Produced-files and clickable file references for the Web GUI: the deliverables row a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience."
+description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the right Sidebar. The linked paths come from successful mutations and explicit deliveries, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together.
+This package renders the changed-files card a finished turn ends with — the files the turn changed, with the line counts the Host recorded from git snapshots — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together.
 
 ## Table of Contents
 
@@ -25,18 +25,18 @@ This package renders the deliverables row a finished turn ends with — the file
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only.
+Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a turn that changed no file, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain; outside a git repository the summary lists file-tool edits only.
 
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
 
-The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
 
 The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
 
-### The row
+### The changed-files card
 
-The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
+The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
 
 ### Inline-code links
 
@@ -50,9 +50,9 @@ The closing prose links produced or delivered paths: an inline-code token resolv
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
 
-Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
 </details>
 
@@ -61,10 +61,12 @@ Native opening uses an authenticated POST addressed by the viewed Session, event
 <a id="further-exploration"></a>
 ## Further Exploration
 
-Read these pages when the deliverables surface is not enough. They move from the row to the turn-tail hole and the decisions behind the vocabulary.
+Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary.
 
+- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders.
 - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
-- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md).
+- [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row.
+- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md).
 - [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose.
 - [Client package map](../README.md) — adjacent browser UI packages.
 
@@ -81,7 +83,7 @@ One fixed paragraph instructs the model to name primary files from successful cr
 
 #### Token effect
 
-One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text.
+One fixed prompt paragraph whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text.
 
 #### KV Cache effect
 
@@ -95,9 +97,11 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and clickable references.
+- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`.
 - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
-- **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
+- **Folder opening needs a Host desktop** — without one the card header is inert and rows fall back to the right Sidebar's text preview, which shows files only.
+- **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract.
+- **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 17 - 13
packages/client/ui-deliverables/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "Web GUI 的产出文件与可点击文件引用:已完成轮次末尾的产出文件行,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。"
+description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。链接路径来自成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。
+本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 从 git 快照记录的增删行数——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,改动文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。
 
 ## 目录
 
@@ -25,18 +25,18 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略项只显示为标签。
+与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——本轮没有改动任何文件、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接;工作区不在 git 仓库内时摘要只列文件工具的编辑。
 
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
-### 该行
+### 改动文件卡片
 
-“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
+卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
 
 ### 行内代码链接
 
@@ -50,9 +50,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
 
-原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
+原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -61,10 +61,12 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 <a id="further-exploration"></a>
 ## 进一步探索
 
-当产出物面不够用时阅读以下页面。它们从该行进入 turn-tail 洞与词表背后的决策。
+当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。
 
+- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。
 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
-- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。
+- [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。
+- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。
 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。
 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。
 
@@ -81,7 +83,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 #### Token 影响
 
-加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
+加载本包时增加一段固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
 
 #### KV Cache 影响
 
@@ -95,9 +97,11 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用。
+- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
-- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
+- **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。
+- **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。
+- **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 2 - 1
packages/client/ui-deliverables/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-deliverables",
-  "description": "Produced-files turn tail and clickable final-response file references for Web",
+  "description": "Changed-files card, delivery cards, and clickable final-response file references for Web",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -67,6 +67,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-tool-present": "workspace:^",
+    "@deepseek-ai/dsh-workspace-changes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",

+ 73 - 0
packages/client/ui-deliverables/src/changes.ts

@@ -0,0 +1,73 @@
+/** Validate workspace-change records that cross the Host route and address their summary and native-open actions. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types'
+
+/** Authenticated GET route serving one announced change summary while its Session lives. */
+export const CHANGED_FILES_PATH = '/api/changes.summary'
+
+/** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */
+export const CHANGES_OPEN_PATH = '/api/changes.open'
+
+/** The summary fields the route serves; the Host keeps the working directory and snapshot ids to itself. */
+export type ChangesSummary = Pick<WorkspaceChangesSummary, 'turn' | 'files' | 'total' | 'added' | 'deleted'>
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Validate one changed-file record read from the summary route.
+ * @param value - decoded JSON.
+ * @returns whether the record carries a path, a display path, and line counts.
+ */
+export function isChangedFile(value: unknown): value is WorkspaceChangedFile {
+  if (!isRecord(value)) return false
+  const { path, display, added, deleted, binary } = value
+  return typeof path === 'string' && path.length > 0 && typeof display === 'string' && display.length > 0
+    && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && (binary === undefined || binary === true)
+}
+
+/**
+ * Validate a summary read from the summary route.
+ * @param value - decoded JSON.
+ * @returns whether the value identifies a turn, a complete file list, the total count, and the line totals.
+ */
+export function isChangesSummary(value: unknown): value is ChangesSummary {
+  if (!isRecord(value)) return false
+  const { turn, files, total, added, deleted } = value
+  return Number.isSafeInteger(turn) && (turn as number) >= 1 && Number.isSafeInteger(total)
+    && Number.isSafeInteger(added) && Number.isSafeInteger(deleted)
+    && Array.isArray(files) && files.every(isChangedFile)
+}
+
+/**
+ * Validate the `workspace/changes` event data read from a Session log.
+ * @param value - decoded durable event data.
+ * @returns whether the event names a turn.
+ */
+export function isChangesEvent(value: unknown): value is { turn: number } {
+  return isRecord(value) && Number.isSafeInteger(value.turn) && (value.turn as number) >= 1
+}
+
+/**
+ * Build authenticated coordinates for the summary one `workspace/changes` event announced.
+ * @param sessionId - owning Session.
+ * @param seq - event sequence.
+ * @returns same-origin summary URL.
+ */
+export function changesSummaryUrl(sessionId: SessionId, seq: number): string {
+  return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}`
+}
+
+/**
+ * Build authenticated coordinates for a changed file or the changed files' common folder.
+ * @param sessionId - owning Session.
+ * @param seq - workspace/changes event sequence.
+ * @param index - original index in the summary's files array, or null for the common folder.
+ * @returns same-origin action URL.
+ */
+export function changedFileUrl(sessionId: SessionId, seq: number, index: number | null): string {
+  const query = new URLSearchParams({ sessionId, seq: String(seq) })
+  if (index !== null) query.set('index', String(index))
+  return `${CHANGES_OPEN_PATH}?${query}`
+}

+ 26 - 0
packages/client/ui-deliverables/src/client/ChangedFiles.module.css

@@ -0,0 +1,26 @@
+/** Changed-files card: filled header, code-font rows with line counts, and a bottom fold control. */
+.card { --changes-fill: var(--dsw-static-neutral-50); --changes-hover: var(--dsw-static-neutral-100); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 16px; color: var(--dsw-alias-label-primary); }
+:global(body[data-ds-dark-theme]) .card { --changes-fill: var(--dsw-static-neutral-850); --changes-hover: var(--dsw-static-neutral-800); }
+.header { display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 12px 16px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; }
+button.header { cursor: pointer; transition: background-color 120ms ease; }
+button.header:hover:not(:disabled), button.header:focus-visible { background: var(--changes-hover); }
+button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); }
+button.header:disabled { cursor: progress; }
+.tile { display: grid; flex: none; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-link); color: var(--dsw-static-neutral-00); }
+.titles { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
+.title { overflow: hidden; font-size: 14px; font-weight: 500; line-height: 22px; text-overflow: ellipsis; white-space: nowrap; }
+.stat { display: inline-flex; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
+.stat[data-error='true'], .counts[data-error='true'] { color: var(--dsw-alias-state-error-primary); }
+.added { color: var(--dsw-alias-state-success-primary); }
+.deleted { color: var(--dsw-alias-state-error-primary); }
+.list { margin: 0; padding: 4px 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l1); }
+.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 6px 16px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; text-align: left; }
+.row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
+.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); }
+.row:disabled { cursor: progress; }
+.path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
+.toggle { display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; width: 100%; margin: 0; padding: 8px 16px; border: 0; border-top: 0.5px solid var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; text-align: left; }
+.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
+.toggle svg { flex: none; width: 14px; height: 14px; }
+@media (pointer: coarse) { .row, .toggle { min-height: 44px; } }

+ 108 - 0
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -0,0 +1,108 @@
+/** The changed-files card: a folder-opening header, per-file line counts, and a three-row fold. */
+import { useState } from 'react'
+import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changedFileUrl, type ChangesSummary } from '../changes.ts'
+import type { PresentedHost } from '../presented.ts'
+import { IconCodeBracketsOutline16 } from './icons.tsx'
+import type { PresentedOpenPhase } from './present-open.ts'
+import type { NS } from './locales.ts'
+import css from './ChangedFiles.module.css'
+
+/** Rows shown before the fold; the design's summary height for a closing message. */
+const COLLAPSED_ROWS = 3
+
+const GROUPED = new Intl.NumberFormat('en-US')
+
+/**
+ * Gesture state worth showing in place of the counts: pending, or failed. A
+ * completed open shows the counts again. The card never reveals, so only open
+ * phases occur.
+ */
+function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.error' | 'presented.nativeUnavailable'; failed: boolean } | undefined {
+  switch (phase) {
+    case 'opening': return { key: 'presented.opening', failed: false }
+    case 'error': return { key: 'presented.error', failed: true }
+    case 'nativeUnavailable': return { key: 'presented.nativeUnavailable', failed: true }
+    default: return undefined
+  }
+}
+
+/** Added and deleted line counts in the card's colors. */
+function Counts({ added, deleted, t }: { added: number; deleted: number } & PropsLocale<typeof NS>) {
+  return <>
+    <span className={css.added}>{t('changes.added', { count: GROUPED.format(added) })}</span>
+    <span className={css.deleted}>{t('changes.deleted', { count: GROUPED.format(deleted) })}</span>
+  </>
+}
+
+/**
+ * Render one turn's changed files. Rows open files in the Host's default
+ * application when a desktop is available and otherwise preview them in the
+ * right Sidebar; the header opens the files' common folder only with a desktop.
+ * @param props - the recorded summary, Host capabilities, gesture status, openers, and localized copy.
+ * @returns the card.
+ */
+export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: {
+  /** The served summary with the sequence of the event that announced it. */
+  changes: Pick<ChangesSummary, 'files' | 'total' | 'added' | 'deleted'> & { seq: number }
+  cwd: string | undefined
+  sessionId: SessionId
+  host: PresentedHost | null
+  phases: Record<string, PresentedOpenPhase | undefined>
+  onOpen: (index: number | null) => void
+  openFile: (path: string) => void
+} & PropsLocale<typeof NS>) {
+  const [expanded, setExpanded] = useState(false)
+  const native = host !== null && host.available
+  const foldable = changes.files.length > COLLAPSED_ROWS
+  const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files
+  const folder = gesture(phases[changedFileUrl(sessionId, changes.seq, null)])
+  const summary = <>
+    <span className={css.tile}><IconCodeBracketsOutline16 size={18} /></span>
+    <span className={css.titles}>
+      <span className={css.title}>{t('changes.title', { count: String(changes.total) })}</span>
+      <span className={css.stat} role={folder === undefined ? undefined : 'status'} data-error={folder?.failed || undefined}>
+        {folder === undefined
+          ? <Counts t={t} added={changes.added} deleted={changes.deleted} />
+          : t(folder.failed ? 'changes.folderError' : 'changes.folderOpening')}
+      </span>
+    </span>
+  </>
+  return <div className={css.card} data-changed-files>
+    {native
+      ? <button type="button" className={css.header} aria-label={t('changes.openFolder')}
+        disabled={folder !== undefined && !folder.failed} onClick={() => { onOpen(null) }}>{summary}</button>
+      : <div className={css.header}>{summary}</div>}
+    <ul className={css.list}>
+      {rows.map((file, index) => {
+        const phase = phases[changedFileUrl(sessionId, changes.seq, index)]
+        const status = gesture(phase)
+        // A file without a verified Host path falls back to the Sidebar preview the status names.
+        const opensNatively = native && phase !== 'nativeUnavailable'
+        return <li key={file.display}>
+          <button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
+            aria-label={t(opensNatively ? 'changes.openFile' : 'presented.previewButton', { name: file.display })}
+            disabled={status !== undefined && !status.failed}
+            onClick={() => { if (opensNatively) onOpen(index); else openFile(file.path) }}>
+            <span className={css.path}>{file.display}</span>
+            <span className={css.counts} role={status === undefined ? undefined : 'status'} data-error={status?.failed || undefined}>
+              {status !== undefined ? t(status.key)
+                : file.binary === true ? t('changes.binary')
+                  : <Counts t={t} added={file.added} deleted={file.deleted} />}
+            </span>
+          </button>
+        </li>
+      })}
+    </ul>
+    {foldable && <button type="button" className={css.toggle}
+      aria-expanded={expanded}
+      aria-label={t(expanded ? 'changes.collapseAria' : 'changes.expandAria', { count: String(changes.files.length) })}
+      onClick={() => { setExpanded(value => !value) }}>
+      <span>{t(expanded ? 'changes.collapse' : 'changes.all', { count: String(changes.files.length) })}</span>
+      {expanded ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
+    </button>}
+  </div>
+}

+ 1 - 1
packages/client/ui-deliverables/src/client/Deliverables.module.css

@@ -2,7 +2,7 @@
 .root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; }
 /* The turn-tail gap alone separates adjacent file sections. The default top
    margin remains when deliveries are the first file section after prose. */
-.root[data-after-produced-files='true'] { margin-top: 0; }
+.root[data-after-changes='true'] { margin-top: 0; }
 :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); }
 .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
 .presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; }

+ 36 - 16
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -1,66 +1,86 @@
-/** Existing changed-file chips and explicitly declared files for a closing turn. */
+/** The changed-files card, shown only while the Host serves the turn's summary, and explicitly declared files for a closing turn. */
 import { useEffect, useState } from 'react'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PresentedOpenController } from './present-open.ts'
-import { ProducedFiles } from './ProducedFiles.tsx'
-import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts'
+import type { ChangesSummaryStore } from './changes-summary.ts'
+import { ChangedFiles } from './ChangedFiles.tsx'
+import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
+import { changesSummaryUrl } from '../changes.ts'
 import { presentedFileUrl } from '../presented.ts'
 import { PresentedFileCard } from './PresentedFileCard.tsx'
 import css from './Deliverables.module.css'
 
-interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] }
+interface DeliverablesMatch { changes: ChangesTurnData | null; presented: readonly PresentedPath[] }
 
 const COLLAPSED_PRESENTED_COUNT = 4
 
-/** Native-open callbacks and shared gesture status supplied by the plugin. */
+/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
 export interface DeliverablesInjected {
   hooks: {
     presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
     presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
+    changesSummary: ObservableSnapshot<ReturnType<ChangesSummaryStore['state']['getSnapshot']>>
   }
   reloadPresentedHost: PresentedOpenController['loadHost']
+  loadChangesSummary: ChangesSummaryStore['load']
   openPresented: PresentedOpenController['open']
+  openChanged: PresentedOpenController['openChanged']
 }
 
 /**
- * Claim turns containing modified paths or declared files.
+ * Claim turns with a change announcement or declared files.
  * @param owner - closing turn.
- * @returns matched files, or null for an empty turn.
+ * @returns matched announcement and deliveries, or null for a turn with neither.
  */
 export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null {
-  const produced = selectProducedFiles(owner) ?? []
+  const changes = changesForClosing(owner)
   const presented = presentedForClosing(owner)
-  return produced.length + presented.length === 0 ? null : { produced, presented }
+  return changes === null && presented.length === 0 ? null : { changes, presented }
 }
 
 /**
- * Render workspace file actions and default-application buttons for declared files.
- * @param props - matched files, workspace opener, and localized copy.
+ * Render the changed-files card, once the Host has served the announced
+ * summary and it lists a file, and default-application buttons for declared
+ * files. A summary the Host no longer serves leaves no card.
+ * @param props - matched announcement and files, workspace opener, and localized copy.
  * @returns the closing turn's file rows.
  */
-export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
+export function Deliverables({
+  matched, openFile, t, sessionId, useSessions, openPresented, openChanged, usePresentedOpen, usePresentedHost, useChangesSummary,
+  reloadPresentedHost, loadChangesSummary,
+}: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
 } & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
   const [expanded, setExpanded] = useState(false)
   const cwd = useSessions(state => state.byId[sessionId]?.cwd)
   const states = usePresentedOpen(value => value)
   const host = usePresentedHost(value => value)
+  const announced = matched.changes
+  const summary = useChangesSummary(value => announced === null ? undefined : value[changesSummaryUrl(sessionId, announced.seq)])
+  useEffect(() => {
+    if (announced !== null && summary === undefined) void loadChangesSummary(sessionId, announced.seq)
+  }, [announced, summary, sessionId, loadChangesSummary])
+  const changes = announced !== null && typeof summary === 'object' && summary.files.length > 0
+    ? { seq: announced.seq, ...summary }
+    : null
   const collapsible = matched.presented.length > COLLAPSED_PRESENTED_COUNT
   const presented = collapsible && !expanded
     ? matched.presented.slice(0, COLLAPSED_PRESENTED_COUNT)
     : matched.presented
   useEffect(() => {
-    if (matched.presented.length > 0 && host === null) void reloadPresentedHost()
-  }, [matched.presented.length, host, reloadPresentedHost])
+    if (host === null) void reloadPresentedHost()
+  }, [host, reloadPresentedHost])
   return <>
-    {matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
+    {changes !== null && <ChangedFiles changes={changes} cwd={cwd} sessionId={sessionId}
+      host={host === 'error' ? null : host} phases={states} t={t} openFile={openFile}
+      onOpen={(index) => { void openChanged(sessionId, changes.seq, index) }} />}
     {matched.presented.length > 0 && <div
       className={css.root}
-      data-after-produced-files={matched.produced.length > 0 || undefined}
+      data-after-changes={changes !== null || undefined}
     >
       {host === 'error' && <div className={css.hostStatus}>
         <span>{t('presented.hostError')}</span>

+ 0 - 159
packages/client/ui-deliverables/src/client/ProducedFiles.module.css

@@ -1,159 +0,0 @@
-/* Turn-tail produced-files summary with an optional native-folder action. */
-
-.root {
-  display: grid;
-  grid-template-columns: max-content minmax(0, 1fr);
-  align-items: start;
-  column-gap: 8px;
-  margin-top: 4px;
-  font-size: 13px;
-  line-height: 22px;
-}
-
-.label {
-  grid-column: 1;
-  grid-row: 1;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.lane {
-  --produced-file-gap: 8px;
-
-  grid-column: 2;
-  grid-row: 1;
-  display: grid;
-  row-gap: 6px;
-  min-width: 0;
-  container-type: inline-size;
-}
-
-.row {
-  display: flex;
-  flex-wrap: nowrap;
-  align-items: center;
-  gap: var(--produced-file-gap);
-  min-width: 0;
-  overflow: hidden;
-}
-
-/* File names render at natural width and only shrink (with ellipsis) when
-   the row truly runs out of space; the container-query bands still budget
-   96px per chip when deciding how many chips to show. Same link language as
-   markdown anchors and file mentions: link-blue at rest with no underline,
-   dotted underline on hover; the leading glyph names the file's category
-   and rides the link color. */
-.file {
-  box-sizing: border-box;
-  display: inline-flex;
-  align-items: center;
-  gap: 5px;
-  flex: 0 1 auto;
-  min-width: 0;
-  margin: 0;
-  padding: 0;
-  border: none;
-  border-radius: 4px;
-  background: none;
-  color: var(--dsw-alias-link);
-  font: inherit;
-  font-weight: 500;
-  text-decoration: none;
-  cursor: pointer;
-}
-
-/* Flex centering tracks box centers, but the 22px text box carries its
-   glyphs below center (the baseline sits low in the leading); the nudge
-   drops the glyph onto the text's visual center. */
-.fileIcon {
-  flex: none;
-  width: 1.1em;
-  height: 1.1em;
-  position: relative;
-  top: 1.2px;
-}
-
-.fileName {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.file:hover,
-.file:focus-visible {
-  text-decoration: underline dotted;
-  text-underline-offset: 3px;
-}
-
-.file:focus-visible {
-  outline: none;
-  box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
-}
-
-/* Overflow count: the row never silently drops files it did not show. */
-.more {
-  flex: 0 0 auto;
-  display: none;
-  white-space: nowrap;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.more[data-shown='6'] {
-  display: inline;
-}
-
-/* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */
-@container (max-width: 687px) {
-  .file:nth-of-type(6),
-  .more[data-shown='6'] {
-    display: none;
-  }
-
-  .more[data-shown='5'] {
-    display: inline;
-  }
-}
-
-@container (max-width: 583px) {
-  .file:nth-of-type(5),
-  .more[data-shown='5'] {
-    display: none;
-  }
-
-  .more[data-shown='4'] {
-    display: inline;
-  }
-}
-
-@container (max-width: 479px) {
-  .file:nth-of-type(4),
-  .more[data-shown='4'] {
-    display: none;
-  }
-
-  .more[data-shown='3'] {
-    display: inline;
-  }
-}
-
-@container (max-width: 375px) {
-  .file:nth-of-type(3),
-  .more[data-shown='3'] {
-    display: none;
-  }
-
-  .more[data-shown='2'] {
-    display: inline;
-  }
-}
-
-@container (max-width: 271px) {
-  .file:nth-of-type(2),
-  .more[data-shown='2'] {
-    display: none;
-  }
-
-  .more[data-shown='1'] {
-    display: inline;
-  }
-}

+ 0 - 61
packages/client/ui-deliverables/src/client/ProducedFiles.tsx

@@ -1,61 +0,0 @@
-import { LinkIcon, classifyLinkPath } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { basename } from './turn-deliverables.ts'
-import type { NS } from './locales.ts'
-import css from './ProducedFiles.module.css'
-
-/** Maximum number of file chips rendered before the remainder counter. */
-const SHOWN_LIMIT = 6
-
-/** Matched paths, the opener, and the locale seat. */
-export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
-  matched: readonly string[]
-} & PropsLocale<typeof NS>
-
-function moreLabel(t: ProducedFilesProps['t'], count: number): string {
-  return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
-}
-
-/**
- * Render one turn's produced files as openable chips.
- * @param props - selector-matched paths, the chat view's file opener, and the locale seat.
- * @returns The produced-files row.
- */
-export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
-  const shown = paths.slice(0, SHOWN_LIMIT)
-  return (
-    <div className={css.root}>
-      <span className={css.label}>{t('produced.label')}</span>
-      <div className={css.lane}>
-        <div className={css.row} data-produced-files-row>
-          {shown.map(path => (
-            <button
-              key={path}
-              type="button"
-              className={css.file}
-              // The full path is the disambiguator when two turns produce files
-              // that share a basename; the chip itself stays short.
-              title={path}
-              aria-label={t('produced.open', { name: path })}
-              onClick={() => { openFile(path) }}
-            >
-              <LinkIcon kind={classifyLinkPath(path)} className={css.fileIcon} />
-              <span className={css.fileName}>{basename(path)}</span>
-            </button>
-          ))}
-          {shown.map((_, index) => {
-            const shownCount = index + 1
-            const remainder = paths.length - shownCount
-            if (remainder <= 0) return null
-            return (
-              <span key={shownCount} className={css.more} data-shown={shownCount}>
-                {moreLabel(t, remainder)}
-              </span>
-            )
-          })}
-        </div>
-      </div>
-    </div>
-  )
-}

+ 64 - 0
packages/client/ui-deliverables/src/client/changes-summary.ts

@@ -0,0 +1,64 @@
+/** Fetch-once cache of the change summaries the Host serves for announced `workspace/changes` events. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changesSummaryUrl, isChangesSummary, type ChangesSummary } from '../changes.ts'
+
+/** A summary, `'missing'` once the Host no longer serves it, or `'loading'` while the request runs. */
+export type ChangesSummaryState = ChangesSummary | 'missing' | 'loading'
+
+/** One browser plugin's summary reads, cleared on connection replacement and cancelled on disposal. */
+export class ChangesSummaryStore {
+  /** Summary URLs key the state across Sessions and turns. */
+  readonly state = createSnapshotStore<Record<string, ChangesSummaryState | undefined>>({})
+  private readonly lifetime = new AbortController()
+  /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */
+  private generation = new AbortController()
+  private readonly pending = new Set<Promise<void>>()
+
+  /**
+   * Read one summary once; a later read of the same coordinates returns the cached state.
+   * @param sessionId - viewed Session.
+   * @param seq - the announcing event's sequence.
+   * @returns after the state is published.
+   */
+  async load(sessionId: SessionId, seq: number): Promise<void> {
+    const url = changesSummaryUrl(sessionId, seq)
+    if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] !== undefined) return
+    this.state.update((state) => { state[url] = 'loading' })
+    const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal]))
+    this.pending.add(task)
+    try {
+      await task
+    } finally {
+      this.pending.delete(task)
+    }
+  }
+
+  /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */
+  reset(): void {
+    this.generation.abort()
+    this.generation = new AbortController()
+    this.state.set({})
+  }
+
+  /** Cancel outstanding reads and wait until none can publish state. */
+  async dispose(): Promise<void> {
+    this.lifetime.abort()
+    await Promise.all(this.pending)
+  }
+
+  private async read(url: string, signal: AbortSignal): Promise<void> {
+    let next: ChangesSummaryState = 'missing'
+    try {
+      const response = await fetch(url, { signal })
+      if (response.ok) {
+        const value: unknown = await response.json()
+        if (isChangesSummary(value)) next = value
+      }
+    } catch {
+      // A transport failure reads as missing until the connection is replaced and the store reset.
+      next = 'missing'
+    }
+    if (!signal.aborted) this.state.update((state) => { state[url] = next })
+  }
+}

+ 26 - 0
packages/client/ui-deliverables/src/client/icons.tsx

@@ -0,0 +1,26 @@
+/**
+ * Package-local glyph for the changed-files card until the shared icon set
+ * carries it; its props already match the shared icon contract.
+ */
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** Angle brackets, the code mark the card's header tile shows. */
+export const IconCodeBracketsOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg
+    width={size}
+    height={size}
+    className={className}
+    viewBox="0 0 16 16"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+    aria-hidden="true"
+  >
+    <path
+      d="M5.6 3.4 1 8l4.6 4.6M10.4 3.4 15 8l-4.6 4.6"
+      stroke="currentColor"
+      strokeWidth="1.8"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    />
+  </svg>
+)

+ 18 - 14
packages/client/ui-deliverables/src/client/index.ts

@@ -1,11 +1,11 @@
 /**
- * Deliverables plugin, browser half: registers the produced-files row into
- * the chat view's turn-tail chain, and provides the `chatFileMentions`
- * service that links inline-code mentions of produced or delivered files in the closing
- * prose. All policy lives here — the supported mutation calls, mention
- * matching, chip cap, and copy — so
- * composing this plugin out of cordis.yml removes both surfaces entirely;
- * the owning view renders an empty chain and inert prose at zero cost.
+ * Deliverables plugin, browser half: registers the changed-files card and
+ * delivery cards into the chat view's turn-tail chain, and provides the
+ * `chatFileMentions` service that links inline-code mentions of produced or
+ * delivered files in the closing prose. All policy lives here — the supported
+ * mutation calls, mention matching, row cap, and copy — so composing this
+ * plugin out of cordis.yml removes both surfaces entirely; the owning view
+ * renders an empty chain and inert prose at zero cost.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -14,6 +14,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { ChangesSummaryStore } from './changes-summary.ts'
 import { PresentedOpenController } from './present-open.ts'
 import { PresentRow } from './PresentRow.tsx'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
@@ -24,14 +25,11 @@ import {
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** Produced-files row copy. */
+    /** Changed-files card, delivery card, and file-mention copy. */
     'deliverables': DeliverablesKey
   }
 }
 
-export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
-export { producedForClosing } from './turn-deliverables.ts'
-
 /** Required services for the tail-slot registration and its dictionaries. */
 export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session']
 
@@ -41,8 +39,12 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se
  */
 export function apply(ctx: ClientContext): void {
   const opener = new PresentedOpenController()
-  ctx.effect(() => () => opener.dispose())
-  ctx.on('connection/reset', () => { opener.resetHost() })
+  const summaries = new ChangesSummaryStore()
+  ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose()]))
+  ctx.on('connection/reset', () => {
+    opener.resetHost()
+    summaries.reset()
+  })
   ctx.uiConversation.events.register(deliverablesDefinition)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
   ctx.slots.inject(
@@ -52,9 +54,11 @@ export function apply(ctx: ClientContext): void {
       select: selectDeliverables,
       locale: NS,
       inject: (): DeliverablesInjected => ({
-        hooks: { presentedOpen: opener.state, presentedHost: opener.host },
+        hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state },
         reloadPresentedHost: () => opener.loadHost(),
+        loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
         openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
+        openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
       }),
     }, Deliverables),
   )

+ 24 - 8
packages/client/ui-deliverables/src/client/locales.ts

@@ -38,10 +38,18 @@ export const zh = {
   'row.error': '交付失败',
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
-  'produced.label': '本轮文件改动',
-  'produced.moreOne': '+ 1 个文件',
-  'produced.more': '+ {count} 个文件',
-  'produced.open': '打开 {name}',
+  'changes.title': '已编辑 {count} 个文件',
+  'changes.added': '+{count}',
+  'changes.deleted': '-{count}',
+  'changes.binary': '二进制',
+  'changes.openFolder': '打开改动文件所在的文件夹',
+  'changes.openFile': '用默认应用打开 {name}',
+  'changes.all': '全部 {count} 个文件',
+  'changes.expandAria': '展开全部 {count} 个改动文件',
+  'changes.collapse': '收起',
+  'changes.collapseAria': '收起改动文件列表',
+  'changes.folderOpening': '正在打开文件夹…',
+  'changes.folderError': '无法打开文件夹,点击重试',
 }
 
 /** English dictionary (same key set). */
@@ -79,10 +87,18 @@ export const en: Record<DeliverablesKey, string> = {
   'row.error': 'Delivery failed',
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
-  'produced.label': 'Files changed',
-  'produced.moreOne': '+ 1 file',
-  'produced.more': '+ {count} files',
-  'produced.open': 'Open {name}',
+  'changes.title': 'Edited {count} files',
+  'changes.added': '+{count}',
+  'changes.deleted': '-{count}',
+  'changes.binary': 'binary',
+  'changes.openFolder': 'Open the folder containing the changed files',
+  'changes.openFile': 'Open {name} in default app',
+  'changes.all': 'All {count} files',
+  'changes.expandAria': 'Show all {count} changed files',
+  'changes.collapse': 'Collapse',
+  'changes.collapseAria': 'Collapse changed files',
+  'changes.folderOpening': 'Opening folder…',
+  'changes.folderError': 'Could not open folder. Click to retry.',
 }
 
 /** Union of this namespace's dictionary keys. */

+ 19 - 3
packages/client/ui-deliverables/src/client/present-open.ts

@@ -1,6 +1,7 @@
-/** Shared native-open status for delivery cards and closing-message file mentions. */
+/** Shared native-open status for delivery cards, the changed-files card, and closing-message file mentions. */
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changedFileUrl } from '../changes.ts'
 import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
 
 /** State of the latest explicit open gesture for one saved file. */
@@ -26,8 +27,23 @@ export class PresentedOpenController {
    * @param action - default application open or file-manager reveal.
    * @returns after the Host acknowledges opening or the error state is published.
    */
-  async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
-    const url = presentedFileUrl(sessionId, seq, index)
+  open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
+    return this.openUrl(presentedFileUrl(sessionId, seq, index), action)
+  }
+
+  /**
+   * Open one recorded changed file, or the changed files' common folder, in
+   * the Host's default application.
+   * @param sessionId - viewed Session.
+   * @param seq - durable workspace/changes event sequence.
+   * @param index - original file index within that event, or null for the common folder.
+   * @returns after the Host acknowledges opening or the error state is published.
+   */
+  openChanged(sessionId: SessionId, seq: number, index: number | null): Promise<void> {
+    return this.openUrl(changedFileUrl(sessionId, seq, index), 'open')
+  }
+
+  private async openUrl(url: string, action: PresentedAction): Promise<void> {
     const phase = this.state.getSnapshot()[url]
     if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return
     this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' })

+ 29 - 5
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -1,13 +1,15 @@
 /**
  * Turn-scoped produced-file Definition and readers. Client-only and
- * model-free: the vocabulary comes from successful first-party mutation
- * calls, never presentation data or the closing prose.
+ * model-free: produced paths come from successful first-party mutation calls,
+ * changed files from the Host's recorded git summary, and deliveries from
+ * `present`; never from presentation data or the closing prose.
  */
 import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
+import { isChangesEvent } from '../changes.ts'
 import { basename, isPresentedData, isPresentedFile } from '../presented.ts'
 
 /** A declared file with its authorized open coordinates. */
@@ -21,15 +23,21 @@ interface ProducedPath {
   readonly path: string
 }
 
+/** The latest `workspace/changes` announcement of one Turn; the Host serves its summary by this sequence. */
+export interface ChangesTurnData {
+  readonly seq: number
+}
+
 /** Immutable produced-file facts published against one Turn. */
 export interface DeliverablesTurnData {
   readonly produced: readonly ProducedPath[]
   readonly presented?: readonly PresentedPath[]
+  readonly changes?: ChangesTurnData
 }
 
 declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
   interface ConversationTurnDataMap {
-    /** Successful mutation paths accumulated in this Turn. */
+    /** Successful mutation paths, recorded changed files, and deliveries accumulated in this Turn. */
     deliverables: DeliverablesTurnData
   }
 }
@@ -160,6 +168,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
     if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
     if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null
+    if (event.type === 'workspace/changes') return isChangesEvent(event.data) ? { id: String(event.data.turn), role: 'update' } : null
     if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
       return { id: String(event.data.turn), role: 'update' }
     }
@@ -170,6 +179,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     return { turn: match.event.data.turn, calls: new Map(), produced: [] }
   },
   update: (context, match) => {
+    if (match.event.type === 'workspace/changes') return { ...context.state, changes: { seq: match.event.seq } }
     if (match.event.type === 'deliverables/presented') {
       const { files } = match.event.data
       const seq = match.event.seq
@@ -204,16 +214,30 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
       && previous.turn === context.state.turn
       && previous.key === 'deliverables'
       && previous.value.produced === context.state.produced
-      && previous.value.presented === context.state.presented) return previous
+      && previous.value.presented === context.state.presented
+      && previous.value.changes === context.state.changes) return previous
     return {
       kind: 'turn',
       turn: context.state.turn,
       key: 'deliverables',
-      value: { produced: context.state.produced, ...context.state.presented === undefined ? {} : { presented: context.state.presented } },
+      value: {
+        produced: context.state.produced,
+        ...context.state.presented === undefined ? {} : { presented: context.state.presented },
+        ...context.state.changes === undefined ? {} : { changes: context.state.changes },
+      },
     }
   },
 }
 
+/**
+ * The turn's latest change announcement.
+ * @param owner - closing turn.
+ * @returns the announcement, or null when the Host recorded none.
+ */
+export function changesForClosing(owner: TurnTailOwnerProps): ChangesTurnData | null {
+  return owner.turn.data.get('deliverables')?.changes ?? null
+}
+
 /**
  * Select the latest declaration of each path before the closing reply.
  * @param owner - closing turn and sequence.

+ 3 - 2
packages/client/ui-deliverables/src/index.ts

@@ -8,10 +8,11 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-system-prompt'
+import type {} from '@deepseek-ai/dsh-workspace-changes/types'
 import { registerPresentOpen } from './present-open.ts'
 
-/** Services required for file-reference guidance and authenticated native opens of declared files. */
-export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy']
+/** Services required for file-reference guidance, change summaries, and authenticated native opens. */
+export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges']
 
 /** Stable final-response guidance owned by the matching renderer. */
 const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. '

+ 139 - 42
packages/client/ui-deliverables/src/present-open.ts

@@ -1,4 +1,5 @@
-/** Open declared source files verified by the viewed Session's filesystem. */
+/** Serve change summaries and open declared or changed workspace paths verified by the viewed Session's filesystem. */
+import { dirname, isAbsolute, relative, resolve } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
 import type {} from '@deepseek-ai/dsh-api-workspace-files'
@@ -8,11 +9,15 @@ import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-session-query'
 import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
+import { CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts'
 import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts'
 
 /**
- * Register native opening inside Connection's authentication fence.
- * @param ctx - Session lookup, native opener, and route lifetime.
+ * Register the deliverables routes inside Connection's authentication fence:
+ * desktop metadata, change summaries, declared-file actions, and changed-file
+ * or folder opening.
+ * @param ctx - Session lookup, change summaries, native opener, and route lifetime.
  */
 export function registerPresentOpen(ctx: Context): void {
   ctx.connection.fetch.register({
@@ -20,25 +25,73 @@ export function registerPresentOpen(ctx: Context): void {
     fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost,
       { headers: { 'cache-control': 'no-store' } })),
   })
+  ctx.connection.fetch.register({
+    path: CHANGED_FILES_PATH, methods: ['GET'], requestBody: 'buffered',
+    fetch: request => Promise.resolve(handleChangesSummary(ctx, request)),
+  })
   const lifetime = new AbortController()
   const pending = new Set<Promise<Response>>()
   ctx.effect(() => async () => {
     lifetime.abort()
     await Promise.allSettled(pending)
   })
-  ctx.connection.fetch.register({
-    path: PRESENT_OPEN_PATH,
-    methods: ['POST'],
-    requestBody: 'buffered',
-    fetch: (request) => {
-      const task = handlePresentOpen(ctx, new Request(request, {
-        signal: AbortSignal.any([request.signal, lifetime.signal]),
-      }))
-      pending.add(task)
-      void task.then(() => { pending.delete(task) }, () => { pending.delete(task) })
-      return task
-    },
-  })
+  for (const [path, handler] of [[PRESENT_OPEN_PATH, handlePresentOpen], [CHANGES_OPEN_PATH, handleChangesOpen]] as const) {
+    ctx.connection.fetch.register({
+      path,
+      methods: ['POST'],
+      requestBody: 'buffered',
+      fetch: (request) => {
+        const task = handler(ctx, new Request(request, {
+          signal: AbortSignal.any([request.signal, lifetime.signal]),
+        }))
+        pending.add(task)
+        void task.then(() => { pending.delete(task) }, () => { pending.delete(task) })
+        return task
+      },
+    })
+  }
+}
+
+const NUMERIC = /^\d+$/
+
+function coordinate(value: string | null): number | undefined {
+  return value !== null && NUMERIC.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : undefined
+}
+
+/** Translate lookup and filesystem failures into the not-found or failure status the browser retries from. */
+function failureStatus(error: unknown): number {
+  const remote = remoteErrorOf(error)
+  const missing = remote?.code === 'session/not-found' || remote?.code === 'workspace-file/not-found'
+    || remote?.code === 'workspace-file/not-regular-file' || error instanceof Error && 'code' in error
+    && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
+      || error.code === 'ENOENT' || error.code === 'ENOTDIR')
+  return missing ? 404 : 500
+}
+
+/**
+ * Read the addressed event once the Host desktop is known to be available.
+ * @returns the event with its Session header, or the refusal to answer with.
+ */
+async function readTarget(ctx: Context, request: Request, id: string, seq: number): Promise<Awaited<ReturnType<Context['sessionQuery']['readEvent']>> | Response> {
+  request.signal.throwIfAborted()
+  if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
+  return ctx.sessionQuery.readEvent({ sessionId: id as SessionId, seq: seq as SessionSeq, before: 0, after: 0 }, request.signal)
+}
+
+/**
+ * Open one verified Host path. A file is verified through the Session
+ * filesystem; a directory is verified by the Host filesystem mapping alone.
+ * @returns the HTTP status to answer with.
+ */
+async function openVerified(ctx: Context, request: Request, path: string, action: 'open' | 'reveal'): Promise<Response> {
+  const { fs } = ctx
+  const mapped = fs.processPathFromHostPath(path)
+  if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal: request.signal })) !== path) {
+    return new Response('Path has no verified Host path.', { status: 422 })
+  }
+  request.signal.throwIfAborted()
+  await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
+  return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
 }
 
 async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
@@ -46,40 +99,84 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise<Respon
   const action = query.get('action') ?? 'open'
   if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 })
   const id = query.get('sessionId')
-  const seq = query.get('seq')
-  const index = query.get('index')
-  if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index)
-    || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) {
-    return new Response('Invalid Presented file coordinates.', { status: 400 })
-  }
+  const seq = coordinate(query.get('seq'))
+  const index = coordinate(query.get('index'))
+  if (!id || seq === undefined || index === undefined) return new Response('Invalid Presented file coordinates.', { status: 400 })
   try {
-    request.signal.throwIfAborted()
-    if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
-    const { target, session } = await ctx.sessionQuery.readEvent({
-      sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
-    }, request.signal)
-    const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
+    const read = await readTarget(ctx, request, id, seq)
+    if (read instanceof Response) return read
+    const { target, session } = read
+    const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[index] : undefined
     if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 })
     request.signal.throwIfAborted()
-    const { fs, workspaceFiles } = ctx
-    const { absolutePath: path } = await workspaceFiles.stat({
+    const { absolutePath: path } = await ctx.workspaceFiles.stat({
       sessionId: id as SessionId,
       workspaceRoot: session.cwd ?? ctx.sandboxPolicy.workspaceRoot,
     }, file.path, request.signal)
-    const mapped = fs.processPathFromHostPath(path)
-    if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal: request.signal })) !== path) {
-      return new Response('Presented file has no verified Host path.', { status: 422 })
-    }
+    return await openVerified(ctx, request, path, action)
+  } catch (error: unknown) {
     request.signal.throwIfAborted()
-    await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
-    return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
+    return new Response('Presented file unavailable.', { status: failureStatus(error) })
+  }
+}
+
+/**
+ * The deepest directory containing every changed file inside the workspace,
+ * or the workspace itself when no listed file lies inside it.
+ * @param cwd - absolute workspace root.
+ * @param files - the recorded changed files.
+ * @returns an absolute directory inside the workspace.
+ */
+export function commonChangedFolder(cwd: string, files: readonly WorkspaceChangedFile[]): string {
+  let common: string | undefined
+  for (const file of files) {
+    if (isAbsolute(file.path)) continue
+    const directory = dirname(resolve(cwd, file.path))
+    if (common === undefined) common = directory
+    while (relative(common, directory).startsWith('..')) common = dirname(common)
+  }
+  if (common === undefined) return cwd
+  const rel = relative(cwd, common)
+  return rel.startsWith('..') || isAbsolute(rel) ? cwd : common
+}
+
+/** The summary one `workspace/changes` event announced, without the Host working directory; 404 once the Host no longer serves it. */
+function handleChangesSummary(ctx: Context, request: Request): Response {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = coordinate(query.get('seq'))
+  if (!id || seq === undefined) return new Response('Invalid change summary coordinates.', { status: 400 })
+  const summary = ctx.workspaceChanges.summary(id as SessionId, seq)
+  if (summary === undefined) return new Response('Change summary unavailable.', { status: 404 })
+  const { turn, files, total, added, deleted } = summary
+  return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } })
+}
+
+async function handleChangesOpen(ctx: Context, request: Request): Promise<Response> {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = coordinate(query.get('seq'))
+  const rawIndex = query.get('index')
+  const index = rawIndex === null ? null : coordinate(rawIndex)
+  if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 })
+  try {
+    request.signal.throwIfAborted()
+    if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
+    const changes = ctx.workspaceChanges.summary(id as SessionId, seq)
+    if (changes === undefined) return new Response('Change summary unavailable.', { status: 404 })
+    const workspaceRoot = changes.cwd
+    if (index === null) {
+      const folder = commonChangedFolder(workspaceRoot, changes.files)
+      const target = await ctx.fs.resolve(folder, { signal: request.signal })
+      if ((await ctx.fs.stat(target, request.signal))?.type !== 'directory') return new Response('Changed files folder unavailable.', { status: 404 })
+      return await openVerified(ctx, request, ctx.fs.processPath(target), 'open')
+    }
+    const file = changes.files[index]
+    if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 })
+    const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal)
+    return await openVerified(ctx, request, path, 'open')
   } catch (error: unknown) {
     request.signal.throwIfAborted()
-    const remote = remoteErrorOf(error)
-    const missing = remote?.code === 'session/not-found' || remote?.code === 'workspace-file/not-found'
-      || remote?.code === 'workspace-file/not-regular-file' || error instanceof Error && 'code' in error
-      && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
-        || error.code === 'ENOENT' || error.code === 'ENOTDIR')
-    return new Response('Presented file unavailable.', { status: missing ? 404 : 500 })
+    return new Response('Changed file unavailable.', { status: failureStatus(error) })
   }
 }

+ 169 - 0
packages/client/ui-deliverables/tests/changes-open.host.spec.ts

@@ -0,0 +1,169 @@
+/** The change summary route and the changed-file and common-folder native opens over the Host-served summaries. */
+import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
+import { Context } from '@deepseek-ai/cordis'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
+import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts'
+import { changedFileUrl, changesSummaryUrl, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesEvent, isChangesSummary } from '../src/changes.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+
+const changed = (path: string, display = path): WorkspaceChangedFile => ({ path, display, added: 1, deleted: 0 })
+
+async function fixture() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-changes-open-'))
+  cleanups.push(() => rm(root, { recursive: true, force: true }))
+  const cwd = join(root, 'workspace')
+  await mkdir(join(cwd, 'src', 'lib'), { recursive: true })
+  await writeFile(join(cwd, 'src', 'lib', 'a.ts'), 'a')
+  await writeFile(join(cwd, 'src', 'b.ts'), 'b')
+  const outside = join(root, 'outside.txt')
+  await writeFile(outside, 'outside')
+  const data: WorkspaceChangesSummary = {
+    turn: 1, cwd, total: 3, added: 3, deleted: 0, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) },
+    files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')],
+  }
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await ctx.plugin(LocalFileSystem, { cwd })
+  ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never)
+  await ctx.plugin({
+    inject: ['fs', 'sandboxPolicy'],
+    apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) },
+  })
+  const readEvent = vi.fn(async (_request: SessionEventReadRequest) => {
+    throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
+  })
+  ctx.provide('sessionQuery', { readEvent } as never)
+  const summary = vi.fn((sessionId: SessionId, seq: number) => sessionId === 'owner' && seq === 9 ? data : undefined)
+  ctx.provide('workspaceChanges', { summary })
+  const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
+  ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
+  const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
+  await ctx.plugin({
+    inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'],
+    apply: registerPresentOpen,
+  })
+  const handler = connection.createSharedFetchHandler('/api')
+  const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
+  const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`))
+  return { root, cwd, ctx, data, readEvent, open, read, opener, outside, summary }
+}
+
+describe('change summary route', () => {
+  it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => {
+    const { read, data, summary } = await fixture()
+    expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`)
+    const response = await read()
+    expect(response.status).toBe(200)
+    expect(response.headers.get('cache-control')).toBe('no-store')
+    // The working directory and snapshot ids stay on the Host.
+    expect(await response.json()).toEqual({ turn: 1, total: 3, added: 3, deleted: 0, files: data.files })
+    expect((await read('?sessionId=owner&seq=8')).status).toBe(404)
+    expect((await read('?sessionId=other&seq=9')).status).toBe(404)
+    for (const bad of ['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=x', '?sessionId=owner&seq=1.5']) {
+      expect((await read(bad)).status).toBe(400)
+    }
+    expect(summary).toHaveBeenCalledTimes(3)
+  })
+})
+
+describe('changed files native open route', () => {
+  it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
+    const { cwd, open, opener, outside } = await fixture()
+    expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`)
+    expect(changedFileUrl(SessionId('owner'), 9, null)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9`)
+    const response = await open()
+    expect(response.status).toBe(204)
+    expect(response.headers.get('cache-control')).toBe('no-store')
+    expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src', 'lib', 'a.ts')) }, expect.any(AbortSignal))
+    expect((await open('?sessionId=owner&seq=9&index=2')).status).toBe(204)
+    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside))
+  })
+
+  it('opens the deepest folder containing the workspace files, falling back to the workspace', async () => {
+    const { cwd, open, opener, data } = await fixture()
+    expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
+    expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src')) }, expect.any(AbortSignal))
+    data.files = [changed('../escaped.ts', '../escaped.ts'), changed('/etc/hosts', '/etc/hosts')]
+    expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
+    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(cwd))
+    const w = resolve('/w')
+    expect(commonChangedFolder(w, [changed('a/b/c.ts'), changed('a/d.ts'), changed(resolve('/x/y.ts'))])).toBe(resolve(w, 'a'))
+    expect(commonChangedFolder(w, [changed(resolve('/x/y.ts'))])).toBe(w)
+    expect(commonChangedFolder(w, [changed('../up.ts')])).toBe(w)
+  })
+
+  it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])(
+    'rejects invalid coordinates before reading: %s', async (query) => {
+      const { open, readEvent } = await fixture()
+      expect((await open(query)).status).toBe(400)
+      expect(readEvent).not.toHaveBeenCalled()
+    })
+
+  it('refuses unrelated Sessions, forgotten summaries, unknown indices, and missing files', async () => {
+    const { open, readEvent, opener, cwd } = await fixture()
+    expect((await open('?sessionId=other&seq=9&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=9&index=5')).status).toBe(404)
+    expect(readEvent).not.toHaveBeenCalled()
+    await unlink(join(cwd, 'src', 'lib', 'a.ts'))
+    expect((await open()).status).toBe(404)
+    await rm(join(cwd, 'src'), { recursive: true })
+    expect((await open('?sessionId=owner&seq=9')).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('refuses opening without a desktop or a verified Host mapping and reports launcher failures', async () => {
+    const { ctx, open, opener } = await fixture()
+    const desktop = vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: null })
+    expect((await open()).status).toBe(409)
+    desktop.mockRestore()
+    const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
+    expect((await open()).status).toBe(422)
+    expect((await open('?sessionId=owner&seq=9')).status).toBe(422)
+    mapping.mockRestore()
+    opener.mockRejectedValueOnce(new Error('/private/host/path'))
+    const failed = await open()
+    expect(failed.status).toBe(500)
+    expect(await failed.text()).not.toContain('/private/host/path')
+    expect((await open()).status).toBe(204)
+  })
+
+  it('validates served summaries and logged announcements', () => {
+    expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true)
+    expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false)
+    expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false)
+    expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false)
+    expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false)
+    expect(isChangedFile([])).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 0, added: 0, deleted: 0, files: [] })).toBe(true)
+    expect(isChangesSummary({ turn: 1, total: 1, added: 1, deleted: 0, files: [{ path: 'a', display: 'a', added: 1, deleted: 0 }] })).toBe(true)
+    expect(isChangesSummary({ turn: '1', total: 0, added: 0, deleted: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 0, total: 0, added: 0, deleted: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 1.5, added: 0, deleted: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 0, added: 0, deleted: 'x', files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 1, added: 1, deleted: 0, files: [{ path: 'a' }] })).toBe(false)
+    expect(isChangesSummary([])).toBe(false)
+    expect(isChangesEvent({ turn: 1 })).toBe(true)
+    expect(isChangesEvent({ turn: 1, extra: true })).toBe(true)
+    expect(isChangesEvent({ turn: 0 })).toBe(false)
+    expect(isChangesEvent({ turn: '1' })).toBe(false)
+    expect(isChangesEvent(null)).toBe(false)
+  })
+})

+ 246 - 73
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx → packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -1,9 +1,10 @@
 // @vitest-environment jsdom
 /**
- * ui-deliverables browser half: the derivation contract of
- * `producedForClosing` over engine-published Turn data, the row's rendering
- * and opener wiring, and the plugin registrations' fiber-teardown removal
- * (HMR safety) against the real SlotRegistry.
+ * ui-deliverables browser half: the derivation contract of produced paths,
+ * recorded changes, and deliveries over engine-published Turn data, the
+ * changed-files card's rendering and opener wiring, and the plugin
+ * registrations' fiber-teardown removal (HMR safety) against the real
+ * SlotRegistry.
  */
 import { Context } from '@deepseek-ai/cordis'
 import { cleanup, fireEvent, render, within } from '@testing-library/react'
@@ -22,26 +23,31 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
+import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
+import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
 import { PresentedOpenController } from '../src/client/present-open.ts'
-import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
 import {
-  basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles,
-  type DeliverablesTurnData,
+  basename, changesForClosing, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing,
+  selectProducedFiles, type DeliverablesTurnData,
 } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
 import { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
-function openProps(controller = new PresentedOpenController()) {
+function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) {
   controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
   const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
   return {
     useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
     reloadPresentedHost: vi.fn(() => controller.loadHost()),
+    useChangesSummary: <T,>(select: (state: ReturnType<typeof summaries.state.getSnapshot>) => T): T =>
+      select(summaries.state.getSnapshot()),
+    loadChangesSummary: vi.fn((...args: Parameters<ChangesSummaryStore['load']>) => summaries.load(...args)),
     usePresentedHost: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
       select(controller.host.getSnapshot()),
     openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
+    openChanged: vi.fn((...args: Parameters<PresentedOpenController['openChanged']>) => controller.openChanged(...args)),
     usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
       select(controller.state.getSnapshot()),
   }
@@ -424,58 +430,223 @@ describe('produced-file Turn data', () => {
   })
 })
 
-describe('ProducedFiles row', () => {
-  const t = makeTranslate(zh)
+const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; path?: string } = {}) =>
+  ({ path: extra.path ?? display, display, added, deleted, ...extra.binary === true ? { binary: true as const } : {} })
 
-  it('renders the bounded chips and opens the file it was clicked for', () => {
-    const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts']
-    const openFile = vi.fn<(path: string) => void>()
+const changesEvent = (seq: number, turn = 1) => at(seq, 'workspace/changes', { turn })
 
-    const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
-    expect(view.getByText('本轮文件改动')).toBeTruthy()
-    const row = view.container.querySelector('[data-produced-files-row]')
-    if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
-    expect(within(row).getAllByRole('button')).toHaveLength(6)
-    expect(within(row).getByText('+ 2 个文件')).toBeTruthy()
-    const chip = view.getByRole('button', { name: '打开 deep/a.html' })
-    expect(chip.textContent).toBe('a.html')
-    expect(chip.getAttribute('title')).toBe('deep/a.html')
-    expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
-    fireEvent.click(chip)
-    // The row hands over the path it was given; where it opens is the
-    // Sidebar's decision, not this row's.
-    expect(openFile).toHaveBeenCalledWith('deep/a.html')
+describe('announced changes Turn data', () => {
+  it('keeps the latest valid announcement of the turn and ignores malformed ones', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      changesEvent(2),
+      at(3, 'workspace/changes', { turn: 'x' }),
+      at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+      changesEvent(5),
+      at(6, 'turn/start', { turn: 2 }),
+    ])
+    const owner = tailOwner(deliverablesOf(value), 4)
+    expect(changesForClosing(owner)).toEqual({ seq: 5 })
+    expect(selectDeliverables(owner)).toEqual({ changes: { seq: 5 }, presented: [] })
+    expect(changesForClosing(tailOwner(deliverablesOf(value, 2), 8))).toBeNull()
+    expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 8))).toBeNull()
+    expect(changesForClosing(tailOwner(undefined, 8))).toBeNull()
   })
 
-  it('renders a remainder counter after every chip but the last when every file fits', () => {
-    const view = render(<ProducedFiles matched={['a.md', 'b.md', 'c.md']} openFile={() => {}} t={t} />)
-    const row = view.container.querySelector('[data-produced-files-row]')
-    if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
-    expect(within(row).getAllByRole('button')).toHaveLength(3)
-    // One counter per chip that could be the last visible one; the final chip hides nothing.
-    expect([...row.querySelectorAll('[data-shown]')].map(node => node.getAttribute('data-shown'))).toEqual(['1', '2'])
+  it('preserves Turn data identity across unrelated appends', () => {
+    const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2)])
+    const first = deliverablesOf(value)
+    value.append(call(3, 'later', 'read', { file_path: 'a.ts' }))
+    value.flush()
+    expect(deliverablesOf(value)).toBe(first)
   })
+})
 
-  it('offers no folder action, because a directory has no preview to open', () => {
+describe('ChangedFiles card', () => {
+  const files = [
+    changedFile('config/design-token', 42, 11), changedFile('config/feature-flags.json', 143, 32),
+    changedFile('config/launch-plan.yaml', 654, 9), changedFile('src/index.ts', 393, 274),
+    changedFile('~/.zshrc', 0, 0, { binary: true, path: '/home/u/.zshrc' }),
+  ]
+  const changes = { seq: 5 }
+  const served: ChangesSummary = { turn: 1, files, total: 11, added: 1232, deleted: 326 }
+
+  /** A store already holding the summary the Host served for the announced sequence. */
+  function servedStore(summary: ChangesSummary = served, seq = 5) {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [changesSummaryUrl(SessionId('child-session'), seq)]: summary })
+    return summaries
+  }
+
+  function renderCard(
+    controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(),
+  ) {
+    const props = openProps(controller, summaries)
+    props.openChanged.mockResolvedValue(undefined)
     const openFile = vi.fn<(path: string) => void>()
-    const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']
-    const view = render(<ProducedFiles matched={overflowing} openFile={openFile} t={t} />)
-    expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
-    // Nothing in the row reaches the local machine any more.
+    const view = render(<Deliverables {...props} matched={matched} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(locale)} />)
+    return { props, openFile, view }
+  }
+
+  it('reads the announced summary once and renders nothing while it loads, when it is gone, or when it lists no file', async () => {
+    const summaries = new ChangesSummaryStore()
+    const fetchMock = vi.fn(async (input: string | URL | Request) => {
+      const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
+      if (url.endsWith('seq=5')) return Response.json(served)
+      if (url.endsWith('seq=6')) return Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })
+      return new Response('gone', { status: 404 })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const { props, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] as never[] }, summaries)
+    expect(view.container.querySelector('[data-changed-files]')).toBeNull()
+    expect(props.loadChangesSummary).toHaveBeenCalledWith('child-session', 5)
+    await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toEqual(served) })
+    view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+    expect(view.getByText('Edited 11 files')).toBeTruthy()
+    for (const seq of [6, 7]) {
+      view.rerender(<Deliverables {...props} matched={{ changes: { seq }, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+      await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), seq)]).not.toBe('loading') })
+      view.rerender(<Deliverables {...props} matched={{ changes: { seq }, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+      expect(view.container.querySelector('[data-changed-files]')).toBeNull()
+    }
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 7)]).toBe('missing')
+    await summaries.load(SessionId('child-session'), 5)
+    expect(fetchMock).toHaveBeenCalledTimes(3)
+    // A replaced connection forgets every read; a later mount asks the new Host again.
+    summaries.reset()
+    expect(summaries.state.getSnapshot()).toEqual({})
+    fetchMock.mockRejectedValueOnce(new Error('offline'))
+    await summaries.load(SessionId('child-session'), 5)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('missing')
+    fetchMock.mockResolvedValueOnce(Response.json({ turn: 'x' }))
+    await summaries.load(SessionId('child-session'), 8)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 8)]).toBe('missing')
+    await summaries.dispose()
+    await summaries.load(SessionId('child-session'), 9)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 9)]).toBeUndefined()
+  })
+
+  it('sums the header from the Host totals, not from the capped list', () => {
+    const capped = servedStore({ turn: 1, files: files.slice(0, 1), total: 2, added: 50, deleted: 20 })
+    const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] as never[] }, capped)
+    expect(view.getByText('Edited 2 files')).toBeTruthy()
+    expect(view.getByText('+50')).toBeTruthy()
+    expect(view.getByText('-20')).toBeTruthy()
+  })
+
+  it('lets no read started before a reset publish afterwards', async () => {
+    const summaries = new ChangesSummaryStore()
+    let settle!: (response: Response) => void
+    vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((resolve) => { settle = resolve })))
+    const stale = summaries.load(SessionId('child-session'), 5)
+    summaries.reset()
+    const url = changesSummaryUrl(SessionId('child-session'), 5)
+    expect(summaries.state.getSnapshot()[url]).toBeUndefined()
+    settle(Response.json(served))
+    await stale
+    // The reset abandoned the read; a later mount asks the new Host afresh.
+    expect(summaries.state.getSnapshot()[url]).toBeUndefined()
+    await summaries.dispose()
+  })
+
+  it('drops a read that settles after disposal', async () => {
+    const summaries = new ChangesSummaryStore()
+    let settle!: (response: Response) => void
+    vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((resolve) => { settle = resolve })))
+    const loading = summaries.load(SessionId('child-session'), 5)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading')
+    const disposal = summaries.dispose()
+    settle(Response.json(served))
+    await Promise.all([loading, disposal])
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading')
+  })
+
+  it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => {
+    const { props, openFile, view } = renderCard()
+    const card = view.container.querySelector('[data-changed-files]')
+    if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing')
+    expect(within(card).getByText('Edited 11 files')).toBeTruthy()
+    expect(within(card).getByText('+1,232')).toBeTruthy()
+    expect(within(card).getByText('-326')).toBeTruthy()
+    expect(within(card).getAllByRole('listitem')).toHaveLength(3)
+    expect(within(card).getByText('config/design-token')).toBeTruthy()
+    expect(within(card).getByText('+42')).toBeTruthy()
+    expect(within(card).queryByText('src/index.ts')).toBeNull()
+    fireEvent.click(within(card).getByRole('button', { name: 'Open config/feature-flags.json in default app' }))
+    expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, 1)
+    fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' }))
+    expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null)
     expect(openFile).not.toHaveBeenCalled()
+    const expand = within(card).getByRole('button', { name: 'Show all 5 changed files' })
+    expect(expand.getAttribute('aria-expanded')).toBe('false')
+    expect(expand.textContent).toContain('All 5 files')
+    fireEvent.click(expand)
+    expect(within(card).getAllByRole('listitem')).toHaveLength(5)
+    expect(within(card).getByText('binary')).toBeTruthy()
+    expect(within(card).getByRole('button', { name: 'Open ~/.zshrc in default app' }).getAttribute('title')).toBe('/home/u/.zshrc')
+    const collapse = within(card).getByRole('button', { name: 'Collapse changed files' })
+    expect(collapse.getAttribute('aria-expanded')).toBe('true')
+    expect(card.lastElementChild).toBe(collapse)
+    fireEvent.click(collapse)
+    expect(within(card).getAllByRole('listitem')).toHaveLength(3)
   })
 
-  it('uses singular English copy when exactly one file is hidden', () => {
-    const view = render(
-      <ProducedFiles
-        matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
-        openFile={() => {}}
-        t={makeTranslate(en)}
-      />,
-    )
-    const row = view.container.querySelector('[data-produced-files-row]')
-    if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
-    expect(within(row).getByText('+ 1 file')).toBeTruthy()
+  it('previews rows in the Sidebar and offers no folder action without a desktop', () => {
+    const controller = new PresentedOpenController()
+    const { openFile, props, view } = renderCard(controller, zh)
+    controller.host.set('error')
+    view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(zh)} />)
+    expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
+    controller.host.set({ name: 'server', available: false, fileManager: null })
+    view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(zh)} />)
+    expect(view.getByText('已编辑 11 个文件')).toBeTruthy()
+    expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: '在侧边栏打开 config/design-token' }))
+    expect(openFile).toHaveBeenCalledWith('config/design-token')
+    expect(props.openChanged).not.toHaveBeenCalled()
+    expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件')
+  })
+
+  it('shows row and folder gesture states in place of the counts', () => {
+    const controller = new PresentedOpenController()
+    controller.state.set({
+      '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening',
+      '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error',
+      '/api/changes.open?sessionId=child-session&seq=5&index=2': 'nativeUnavailable',
+      '/api/changes.open?sessionId=child-session&seq=5': 'opened',
+    })
+    const { view, openFile, props } = renderCard(controller)
+    expect(view.getByText(en['presented.opening'])).toBeTruthy()
+    expect((view.getByRole('button', { name: 'Open config/design-token in default app' }) as HTMLButtonElement).disabled).toBe(true)
+    expect(view.getByText(en['presented.error']).closest('[data-error]')).toBeTruthy()
+    expect(view.getByText(en['presented.nativeUnavailable'])).toBeTruthy()
+    // A completed folder open leaves the summed counts in place.
+    expect(view.getByText('+1,232')).toBeTruthy()
+    // A row without a verified Host path previews in the Sidebar instead of retrying the native open.
+    fireEvent.click(view.getByRole('button', { name: 'Open config/launch-plan.yaml in sidebar' }))
+    expect(openFile).toHaveBeenCalledWith('config/launch-plan.yaml')
+    expect(props.openChanged).not.toHaveBeenCalled()
+    view.unmount()
+    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opened' })
+    const { view: pending } = renderCard(controller)
+    expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy()
+    expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true)
+    // A completed row open shows its counts again rather than a lasting acknowledgement.
+    expect(pending.queryByText(en['presented.opened'])).toBeNull()
+    expect(pending.getByText('+42')).toBeTruthy()
+    pending.unmount()
+    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' })
+    const { view: failed } = renderCard(controller)
+    expect(failed.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy()
+  })
+
+  it('renders without a fold for three files or fewer and beside delivery cards', () => {
+    const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2, added: 185, deleted: 43 })
+    const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short)
+    expect(view.getByText('Edited 2 files')).toBeTruthy()
+    expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull()
+    expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1)
+    expect(view.container.querySelector('[data-presented-files-row]')?.parentElement?.getAttribute('data-after-changes')).toBe('true')
   })
 })
 
@@ -568,10 +739,17 @@ describe('plugin registration', () => {
     fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
     await face.reloadPresentedHost()
     expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' })
+    fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }))
+    await face.loadChangesSummary(SessionId('child-session'), 5)
+    expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })
     ctx.emit('connection/reset')
     expect(face.hooks.presentedHost.getSnapshot()).toBeNull()
+    // The replaced connection may reach a Host that no longer serves the summaries read so far.
+    expect(face.hooks.changesSummary.getSnapshot()).toEqual({})
     await face.openPresented(SessionId('child-session'), 2, 0)
     expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
+    await face.openChanged(SessionId('child-session'), 5, null)
+    expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened')
     // A turn that produced nothing yields no vocabulary at all.
     expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined()
 
@@ -636,7 +814,7 @@ describe('presented files', () => {
     expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open')
     fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' }))
     expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
-    expect(view.queryByText('Files changed')).toBeNull()
+    expect(view.container.querySelector('[data-changed-files]')).toBeNull()
   })
 })
 
@@ -648,22 +826,25 @@ it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) =
 it.each([{}, { turn: '1', callId: 'bad', files: [] },
   { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] },
   { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null },
-])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => {
+])('ignores malformed delivery data and keeps the recorded changes card: %j', (data) => {
   const value = assembler([
     at(1, 'turn/start', { turn: 1 }),
     call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }),
     result(3, 'write-a'),
     at(4, 'deliverables/presented', data),
+    at(5, 'workspace/changes', { turn: 1 }),
   ])
-  const owner = tailOwner(deliverablesOf(value), 5)
+  const owner = tailOwner(deliverablesOf(value), 6)
   const matched = selectDeliverables(owner)!
-  const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
-  expect(view.getByText('Files changed')).toBeTruthy()
+  const summaries = new ChangesSummaryStore()
+  summaries.state.set({ [changesSummaryUrl(SessionId('session'), 5)]: { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1, added: 1, deleted: 0 } })
+  const view = render(<Deliverables {...openProps(new PresentedOpenController(), summaries)} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByText('Edited 1 files')).toBeTruthy()
   expect(view.queryByText('Deliverables')).toBeNull()
 })
 
 it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => {
-  const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented: [
+  const view = render(<Deliverables {...openProps()} matched={{ changes: null, presented: [
     { path: 'out/report.txt', description: 'Quarterly summary', seq: 2, index: 0 },
     { path: 'LICENSE', seq: 2, index: 1 },
   ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
@@ -673,18 +854,9 @@ it('shows descriptions and falls back to file metadata without hiding extensionl
   expect(view.getByText('report.txt')).toBeTruthy()
 })
 
-it('marks delivery cards that directly follow the produced-files row', () => {
-  const shared = { ...openProps(), openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) }
-  const presented = [{ path: 'report.txt', seq: 2, index: 0 }]
-  const view = render(<Deliverables {...shared} matched={{ produced: ['source.ts'], presented }} />)
-  expect(view.getByText('Files changed')).toBeTruthy()
-  expect(view.container.querySelector('[data-presented-files-row]')?.parentElement
-    ?.getAttribute('data-after-produced-files')).toBe('true')
-})
-
 it('distinguishes PDF, Word, Markdown, and code files with compact decorative card icons', () => {
   const paths = ['report.pdf', 'report.docx', 'README.md', 'index.tsx']
-  const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented:
+  const view = render(<Deliverables {...openProps()} matched={{ changes: null, presented:
     paths.map((path, index) => ({ path, seq: 2, index })),
   }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   const icons = [...view.container.querySelectorAll('[data-presented-file]')].map((card) => {
@@ -697,7 +869,7 @@ it('distinguishes PDF, Word, Markdown, and code files with compact decorative ca
 })
 
 it('lets one delivered file span the complete row without an expansion control', () => {
-  const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented: [
+  const view = render(<Deliverables {...openProps()} matched={{ changes: null, presented: [
     { path: 'report.pdf', seq: 2, index: 0 },
   ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true')
@@ -709,7 +881,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits
   const controller = new PresentedOpenController()
   controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase })
   const props = openProps(controller)
-  const view = render(<Deliverables {...props} matched={{ produced: [], presented: [
+  const view = render(<Deliverables {...props} matched={{ changes: null, presented: [
     { path: 'report.txt', seq: 2, index: 0 },
   ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   expect(view.getByText(en[`presented.${phase}`])).toBeTruthy()
@@ -720,7 +892,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits
 it('explains a missing desktop and retries failed Host metadata', () => {
   const controller = new PresentedOpenController()
   const props = openProps(controller)
-  const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] }
+  const matched = { changes: null, presented: [{ path: 'file.txt', seq: 2, index: 0 }] }
   controller.host.set('error')
   const view = render(<Deliverables {...props} matched={matched} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   props.reloadPresentedHost.mockResolvedValue(undefined)
@@ -732,14 +904,15 @@ it('explains a missing desktop and retries failed Host metadata', () => {
 })
 
 
-it('loads desktop information only when delivery cards appear', () => {
+it('loads desktop information once the tail renders and not again while it is known', () => {
   const controller = new PresentedOpenController()
   const props = openProps(controller)
   controller.host.set(null)
   props.reloadPresentedHost.mockResolvedValue(undefined)
   const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) }
-  const view = render(<Deliverables {...shared} matched={{ produced: ['source.ts'], presented: [] }} />)
-  expect(props.reloadPresentedHost).not.toHaveBeenCalled()
-  view.rerender(<Deliverables {...shared} matched={{ produced: [], presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)
+  const view = render(<Deliverables {...shared} matched={{ changes: { seq: 2 }, presented: [] }} />)
+  expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
+  controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
+  view.rerender(<Deliverables {...shared} matched={{ changes: null, presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)
   expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
 })

+ 16 - 0
packages/client/ui-deliverables/tests/present-open.client.spec.ts

@@ -26,6 +26,22 @@ it('coalesces concurrent card and mention gestures, then allows another open', a
   await controller.dispose()
 })
 
+it('opens changed files and their common folder through their own coordinates', async () => {
+  const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>().mockResolvedValue(new Response(null, { status: 204 }))
+  vi.stubGlobal('fetch', fetcher)
+  const controller = new PresentedOpenController()
+  await controller.openChanged(id, 9, 1)
+  await controller.openChanged(id, 9, null)
+  expect(fetcher.mock.calls.map(call => call[0])).toEqual([
+    '/api/changes.open?sessionId=fork&seq=9&index=1', '/api/changes.open?sessionId=fork&seq=9',
+  ])
+  expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9']).toBe('opened')
+  fetcher.mockResolvedValueOnce(new Response(null, { status: 422 }))
+  await controller.openChanged(id, 9, 1)
+  expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')
+  await controller.dispose()
+})
+
 it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => {
   const fetcher = vi.fn()
   if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 }))

+ 1 - 0
packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -22,6 +22,7 @@ describe('ui-deliverables node plugin', () => {
     ctx.provide('workspaceFiles', {} as never)
     ctx.provide('fs', {} as never)
     ctx.provide('sandboxPolicy', {} as never)
+    ctx.provide('workspaceChanges', {} as never)
     const mounted = ctx.plugin({ apply, inject })
     await mounted.await()
 

+ 3 - 3
packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts

@@ -1,4 +1,4 @@
-/** Source checks for the produced-files and explicit-delivery layout. */
+/** Source checks for the changed-files and explicit-delivery layout. */
 import { readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
@@ -8,10 +8,10 @@ const read = (name: string): string =>
 
 describe('deliverables layout', () => {
   it('keeps the first file-section offset and removes the second', () => {
-    expect(read('ProducedFiles.module.css')).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s)
+    expect(read('ChangedFiles.module.css')).toMatch(/\.card\s*\{[^}]*margin-top:\s*4px/s)
     const deliveries = read('Deliverables.module.css')
     expect(deliveries).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s)
-    expect(deliveries).toMatch(/\.root\[data-after-produced-files='true'\]\s*\{\s*margin-top:\s*0;\s*\}/)
+    expect(deliveries).toMatch(/\.root\[data-after-changes='true'\]\s*\{\s*margin-top:\s*0;\s*\}/)
     expect(deliveries).toMatch(/\.file\s*\{[^}]*height:\s*60px;[^}]*padding:\s*8px 10px/s)
     expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*box-sizing:\s*border-box;[^}]*width:\s*40px;[^}]*height:\s*40px/s)
     expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*border:\s*0\.5px solid var\(--dsw-alias-border-l1\)/s)

+ 5 - 1
packages/client/ui-deliverables/tsconfig.client.json

@@ -7,6 +7,7 @@
   },
   "include": [
     "src/client",
+    "src/changes.ts",
     "src/presented.ts",
     "src/css-modules.d.ts"
   ],
@@ -51,7 +52,10 @@
       "path": "../ui-tool"
     },
     {
-      "path": "../../fs/tool-present"
+      "path": "../../deliverables/tool-present"
+    },
+    {
+      "path": "../../deliverables/workspace-changes"
     },
     {
       "path": "../../util/workspace-path"

+ 5 - 1
packages/client/ui-deliverables/tsconfig.host.json

@@ -7,6 +7,7 @@
   },
   "files": [
     "src/index.ts",
+    "src/changes.ts",
     "src/presented.ts",
     "src/present-open.ts"
   ],
@@ -30,7 +31,10 @@
       "path": "../../llm/llm"
     },
     {
-      "path": "../../fs/tool-present"
+      "path": "../../deliverables/tool-present"
+    },
+    {
+      "path": "../../deliverables/workspace-changes"
     },
     {
       "path": "../../api/session-controller/tsconfig.host.json"

+ 1 - 0
packages/core/session/src/known-event-types.ts

@@ -77,6 +77,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'turn/start',
   'user/message',
   'web/deepseek-search-llm-request',
+  'workspace/changes',
 ])
 
 /** Event types whose model-visible effects require an explicit pure interpreter. */

+ 6 - 0
packages/deliverables/README.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 packages/deliverables/README.md
+README.md: 92b9dfa95c4b5ded19fa2753d51551bafbd03fed
+README.zh.md: ff8e0fe0eba4ed02504e30765ede58c95b78daa0

+ 50 - 0
packages/deliverables/README.md

@@ -0,0 +1,50 @@
+---
+description: "The deliverables group map: the Host plugins that record what a turn hands to the user, explicit file deliveries and observed workspace changes, for users and maintainers navigating the group."
+kind: "package-group"
+---
+
+# packages/deliverables
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The deliverables family records what a turn hands to the user as durable Session events that only clients read: the `present` tool declares final files the model delivered, and the workspace-changes recorder captures the files a turn changed with their line counts from git snapshots. The Web [deliverables plugin](../client/ui-deliverables/README.md) renders both at the end of a turn. Choose this family for a product that shows delivered files and per-turn changes; `present` needs `ctx.tools` and `ctx.fs`, the recorder needs `ctx.subprocess` and a git executable.
+
+## Table of Contents
+
+- [Packages](#packages)
+- [Related documentation](#related-documentation)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## Packages
+
+| Package | Role | ctx key |
+|---|---|---|
+| [`tool-present`](tool-present/README.md) | Declares existing files as final deliverables through the `present` tool | registers on `ctx.tools` |
+| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` |
+
+-----
+
+<a id="related-documentation"></a>
+## Related documentation
+
+- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesSummary` vocabulary, the two durable events, and the summary service.
+- [Web deliverables](../client/ui-deliverables/README.md) — the turn-tail cards and file mentions that render these events.
+- [Present declares workspace source files](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — the delivery decision.
+- [Turn changed-files card](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the snapshot design and coverage rules.
+
+-----
+
+<a id="dev-note"></a>
+## Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 50 - 0
packages/deliverables/README.zh.md

@@ -0,0 +1,50 @@
+---
+description: "deliverables 组导览:记录轮次交给用户的内容的 Host 插件,即显式文件交付与观察到的工作区改动,供浏览本组的用户与维护者阅读。"
+kind: "package-group"
+---
+
+# packages/deliverables
+
+[English](README.md) | 中文
+
+## 概述
+
+deliverables 系列把一轮交给用户的内容记录为只有客户端读取的持久 Session 事件:`present` 工具声明模型交付的最终文件,workspace-changes 记录器用 git 快照捕获一轮改动的文件及其行数。Web 的[交付插件](../client/ui-deliverables/README.zh.md)在轮次末尾渲染两者。需要展示交付文件和每轮改动的产品选择本系列;`present` 需要 `ctx.tools` 与 `ctx.fs`,记录器需要 `ctx.subprocess` 和 git 可执行文件。
+
+## 目录
+
+- [包](#packages)
+- [相关文档](#related-documentation)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## 包
+
+| 包 | 角色 | ctx key |
+|---|---|---|
+| [`tool-present`](tool-present/README.zh.md) | 通过 `present` 工具把已有文件声明为最终交付物 | 注册到 `ctx.tools` |
+| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照汇总每个顶层轮次改动的文件 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` |
+
+-----
+
+<a id="related-documentation"></a>
+## 相关文档
+
+- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesSummary` 的词汇、两个持久事件和摘要服务。
+- [Web 产出物](../client/ui-deliverables/README.zh.md)——渲染这些事件的轮尾卡片与文件提及。
+- [present 声明工作区源文件](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——交付决策。
+- [本轮改动文件卡片](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计与覆盖规则。
+
+-----
+
+<a id="dev-note"></a>
+## 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 1 - 1
packages/fs/tool-present/README.i18n.yaml → packages/deliverables/tool-present/README.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 packages/fs/tool-present/README.md
+#   pnpm run verify-translation-pairing --write packages/deliverables/tool-present/README.md
 README.md: 93003711545f678336a02c9bf8589176f844af65
 README.zh.md: 161efcc3931fbf57175064ccf1179b195320d4b9

+ 0 - 0
packages/fs/tool-present/README.md → packages/deliverables/tool-present/README.md


+ 0 - 0
packages/fs/tool-present/README.zh.md → packages/deliverables/tool-present/README.zh.md


+ 1 - 1
packages/fs/tool-present/package.json → packages/deliverables/tool-present/package.json

@@ -8,7 +8,7 @@
   "repository": {
     "type": "git",
     "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
-    "directory": "packages/fs/tool-present"
+    "directory": "packages/deliverables/tool-present"
   },
   "type": "module",
   "main": "lib/index.js",

+ 0 - 0
packages/fs/tool-present/src/index.ts → packages/deliverables/tool-present/src/index.ts


+ 0 - 0
packages/fs/tool-present/src/types.ts → packages/deliverables/tool-present/src/types.ts


+ 1 - 1
packages/fs/tool-present/tests/built-errors.e2e.ts → packages/deliverables/tool-present/tests/built-errors.e2e.ts

@@ -16,7 +16,7 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js'
 import SystemPrompt from './packages/core/system-prompt/lib/index.js'
 import ToolRuntime from './packages/core/tools/lib/index.js'
 import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js'
-import * as Present from './packages/fs/tool-present/lib/index.js'
+import * as Present from './packages/deliverables/tool-present/lib/index.js'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'

+ 0 - 0
packages/fs/tool-present/tests/present.spec.ts → packages/deliverables/tool-present/tests/present.spec.ts


+ 1 - 1
packages/fs/tool-present/tsconfig.json → packages/deliverables/tool-present/tsconfig.json

@@ -18,7 +18,7 @@
       "path": "../../core/agent"
     },
     {
-      "path": "../fs"
+      "path": "../../fs/fs"
     },
     {
       "path": "../../llm/llm"

Некоторые файлы не были показаны из-за большого количества измененных файлов