Ver Fonte

feat(web): record turn file changes with git snapshots and render the changed-files card

Add the dsh-workspace-changes Host plugin that snapshots the working tree at
turn start and end through a private git index, diffs the two trees, folds in
file-tool hunks for ignored and out-of-tree files, and appends a log-only
workspace/changes event. The Web deliverables plugin renders that event as the
changed-files card in place of the mutation-call row, with default-app and
common-folder opening through a new authenticated route.
creatixchu há 1 semana atrás
pai
commit
f937f4e23b
76 ficheiros alterados com 2951 adições e 654 exclusões
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 1 1
      .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. 45 0
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  6. 45 0
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  7. 5 4
      apps/web/package.json
  8. 148 0
      apps/web/tests/changed-files.e2e.ts
  9. 1 1
      apps/web/tests/changed-files.overlay.yml
  10. 0 216
      apps/web/tests/produced-files.e2e.ts
  11. 11 0
      apps/web/tests/scaffold.ts
  12. 1 1
      apps/web/tsconfig.json
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 24 0
      docs/config-catalog.md
  15. 24 0
      docs/config-catalog.zh.md
  16. 2 2
      docs/event-producer-consumer.i18n.yaml
  17. 4 4
      docs/event-producer-consumer.md
  18. 4 4
      docs/event-producer-consumer.zh.md
  19. 2 2
      docs/module-graph.i18n.yaml
  20. 7 0
      docs/module-graph.md
  21. 7 0
      docs/module-graph.zh.md
  22. 2 2
      docs/persistence-catalog.i18n.yaml
  23. 13 0
      docs/persistence-catalog.md
  24. 13 0
      docs/persistence-catalog.zh.md
  25. 8 2
      packages/bundle/web-app/cordis.patch.yml
  26. 1 0
      packages/bundle/web-app/package.json
  27. 2 2
      packages/client/README.i18n.yaml
  28. 1 1
      packages/client/README.md
  29. 1 1
      packages/client/README.zh.md
  30. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  31. 14 11
      packages/client/ui-deliverables/README.md
  32. 14 11
      packages/client/ui-deliverables/README.zh.md
  33. 2 1
      packages/client/ui-deliverables/package.json
  34. 43 0
      packages/client/ui-deliverables/src/changes.ts
  35. 26 0
      packages/client/ui-deliverables/src/client/ChangedFiles.module.css
  36. 113 0
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  37. 1 1
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  38. 18 14
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  39. 0 159
      packages/client/ui-deliverables/src/client/ProducedFiles.module.css
  40. 0 61
      packages/client/ui-deliverables/src/client/ProducedFiles.tsx
  41. 9 11
      packages/client/ui-deliverables/src/client/index.ts
  42. 26 8
      packages/client/ui-deliverables/src/client/locales.ts
  43. 19 3
      packages/client/ui-deliverables/src/client/present-open.ts
  44. 36 5
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  45. 123 41
      packages/client/ui-deliverables/src/present-open.ts
  46. 146 0
      packages/client/ui-deliverables/tests/changes-open.host.spec.ts
  47. 144 71
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  48. 16 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  49. 3 3
      packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts
  50. 4 0
      packages/client/ui-deliverables/tsconfig.client.json
  51. 4 0
      packages/client/ui-deliverables/tsconfig.host.json
  52. 1 0
      packages/core/session/src/known-event-types.ts
  53. 2 2
      packages/fs/README.i18n.yaml
  54. 1 0
      packages/fs/README.md
  55. 1 0
      packages/fs/README.zh.md
  56. 6 0
      packages/fs/workspace-changes/README.i18n.yaml
  57. 101 0
      packages/fs/workspace-changes/README.md
  58. 101 0
      packages/fs/workspace-changes/README.zh.md
  59. 58 0
      packages/fs/workspace-changes/package.json
  60. 213 0
      packages/fs/workspace-changes/src/git.ts
  61. 149 0
      packages/fs/workspace-changes/src/index.ts
  62. 81 0
      packages/fs/workspace-changes/src/numstat.ts
  63. 78 0
      packages/fs/workspace-changes/src/paths.ts
  64. 188 0
      packages/fs/workspace-changes/src/recorder.ts
  65. 38 0
      packages/fs/workspace-changes/src/types.ts
  66. 125 0
      packages/fs/workspace-changes/tests/git.spec.ts
  67. 82 0
      packages/fs/workspace-changes/tests/loader-composition.spec.ts
  68. 46 0
      packages/fs/workspace-changes/tests/numstat.spec.ts
  69. 46 0
      packages/fs/workspace-changes/tests/paths.spec.ts
  70. 334 0
      packages/fs/workspace-changes/tests/plugin.spec.ts
  71. 67 0
      packages/fs/workspace-changes/tests/support.ts
  72. 33 0
      packages/fs/workspace-changes/tsconfig.json
  73. 49 0
      pnpm-lock.yaml
  74. 1 0
      scripts/verify-package-readme-model-experience.ts
  75. 2 1
      tsconfig.base.json
  76. 2 1
      tsconfig.host.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: 40a5656eee1d90be09c10660706584e6b61848e9
+2026-09-08-present-workspace-source-files.zh.md: 647728ec7befd4d7ecefba29af7d89de2469aac2

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

@@ -16,7 +16,7 @@ The tool description requires `present` after writing a file the user asked to r
 
 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.
 

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

@@ -16,7 +16,7 @@ Status: implemented
 
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 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: f04e694853a89be75c642325d636c6a8fd496ddb
+2026-09-11-turn-changed-files-card.zh.md: 52474eaa4dc4b312dadb475c590e25146944c922

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

@@ -0,0 +1,45 @@
+# 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/fs/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event 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`. 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, 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. Three tiers cover the workspace: a working directory inside a repository uses that repository; a working directory outside any repository uses a shadow repository under the Harness home, with its git directory there and its work tree pointing at the working directory, so the user's directory gains no `.git`; without git the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Shadow repositories take their excludes from configuration because no `.gitignore` exists there.
+
+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, so 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 event carries both snapshot tree ids for a later full-file comparison.
+
+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 and the card renders from the log alone. The Client keeps the latest event per turn.
+
+## 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; the card is simply absent until git exists.
+
+**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 tree ids and persisted hunks leave that open.
+
+**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.
+
+## 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 repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. 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. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
+
+Focused tests cover repository and shadow tiers with real git, coverage classification, 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 scenarios replay the recorder end to end.

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

@@ -0,0 +1,45 @@
+# 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/fs/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。
+
+记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
+
+git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。工作区分三档:位于仓库内的工作目录使用该仓库;不在任何仓库内的工作目录使用 Harness home 下的影子仓库,其 git 目录在那里而工作树指向工作目录,用户目录里不会多出 `.git`;没有 git 时插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。影子仓库的排除规则来自配置,因为那里没有 `.gitignore`。
+
+快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
+
+列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。事件携带两个快照 tree id,供将来做整文件对比。
+
+记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖,卡片仅凭日志即可渲染。Client 保留每轮最新的事件。
+
+## Alternatives considered
+
+**给修改调用行补上 hunk 计数**会在逐调用 diff 卡片旁保留第二个更弱的实现,且仍然漏掉 shell 编辑;git 不管工具是什么都能看到每次写入。
+
+**轮末直接与 `HEAD` 比较**只需一条命令,但会把用户未提交的工作算到本轮。
+
+**每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。
+
+**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片直接不显示。
+
+**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;tree id 与持久化的 hunk 为此留出了空间。
+
+**用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。
+
+## Consequences
+
+每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。
+
+只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景新增该事件与卡片。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
+
+聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。

+ 5 - 4
apps/web/package.json

@@ -38,23 +38,24 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-web": "workspace:^",
     "@deepseek-ai/dsh-cmdline": "workspace:^",
-    "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
+    "@deepseek-ai/dsh-launch-environment": "workspace:^",
+    "@deepseek-ai/dsh-pwsh-local": "workspace:^",
+    "@deepseek-ai/dsh-workspace-changes": "workspace:^",
     "@types/node": "^22.0.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
     "@types/ws": "8.18.1",
     "@vitejs/plugin-react": "^4.0.0",
-    "http-server": "^14.1.1",
     "fflate": "^0.8.2",
+    "http-server": "^14.1.1",
     "playwright": "^1.49.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "typescript": "^6.0.3",
     "vite": "^6.0.0",
     "vitest": "^4.1.8",
-    "ws": "8.21.0",
-    "@deepseek-ai/dsh-launch-environment": "workspace:^"
+    "ws": "8.21.0"
   }
 }

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

@@ -0,0 +1,148 @@
+// Web e2e scenario: the changed-files card a finished turn ends with.
+// Cold-seeds one recorded change summary (zero model calls), then verifies the
+// real assembled card folds after three rows, expands to the complete list,
+// and offers native file and folder actions when the Host desktop is pinned on.
+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 { createAssistantMessage, 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 type {} from '@deepseek-ai/dsh-workspace-changes'
+import {
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const MODE = webSnapshotMode()
+const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url))
+const SEED_ID = 'changed-files-web-e2e'
+const DONE = 'CHANGED_FILES_DONE'
+
+/** Eleven files in display order, as the Host records them. */
+const CHANGED = [
+  ['config/design-token', 42, 11],
+  ['config/feature-flags.json', 143, 32],
+  ['config/launch-plan.yaml', 654, 9],
+  ['src/app.ts', 120, 80],
+  ['src/index.html', 30, 4],
+  ['src/manifest.yaml', 12, 0],
+  ['src/notes.txt', 8, 8],
+  ['src/preview.svg', 0, 0],
+  ['src/schema.json', 90, 60],
+  ['src/styles.css', 100, 100],
+  ['关于我.md', 33, 22],
+] as const
+
+/** Build one settled turn whose recorded summary lists eleven changed files. */
+function changedFixture(): string {
+  const session = Session.create(SessionId('changed-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: 'Update the site files.' }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', {
+    title: 'Changed files fold', messageSeqs: [user.seq], source: { kind: 'fallback' },
+  })
+  session.append('step/start', { turn: 1, step: 1 })
+  session.append('assistant/message', {
+    stream: [],
+    turn: 1,
+    step: 1,
+    message: createAssistantMessage({
+      content: [{ type: 'text', text: `Updated the site.\n\n${DONE}` }],
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('workspace/changes', {
+    turn: 1,
+    total: CHANGED.length,
+    snapshot: { before: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', after: 'c1a2b3d4e5f60718293a4b5c6d7e8f9012345678' },
+    files: CHANGED.map(([path, added, deleted]) => ({
+      path, display: path, added, deleted, ...(path.endsWith('.svg') ? { binary: true as const } : {}),
+    })),
+  })
+  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 changed', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
+    await seedSession(scaffold, changedFixture(), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.setViewportSize({ width: 1400, 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')('folds an eleven-file summary after three rows and expands it in place', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-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 card = page.locator('[data-changed-files]')
+    await card.waitFor({ timeout: 15_000 })
+    await expect.poll(() => card.getByText('Edited 11 files', { exact: true }).count()).toBe(1)
+    expect(await card.getByText('+1,232', { exact: true }).count()).toBe(1)
+    expect(await card.getByText('-326', { exact: true }).count()).toBe(1)
+    const rows = card.getByRole('listitem')
+    await expect.poll(() => rows.count()).toBe(3)
+    expect(await rows.nth(0).innerText()).toContain('config/design-token')
+    expect(await rows.nth(0).innerText()).toContain('+42')
+    expect(await rows.nth(2).innerText()).toContain('config/launch-plan.yaml')
+    // The pinned desktop turns the header into the folder action and rows into default-app opens.
+    await expect.poll(() => card.getByRole('button', { name: 'Open the folder containing the changed files' }).count()).toBe(1)
+    expect(await card.getByRole('button', { name: 'Open config/design-token in default app' }).count()).toBe(1)
+
+    const expand = card.getByRole('button', { name: 'Show all 11 changed files' })
+    expect(await expand.innerText()).toContain('All 11 files')
+    await expand.click()
+    await expect.poll(() => rows.count()).toBe(11)
+    expect(await rows.nth(7).innerText()).toContain('binary')
+    expect(await rows.nth(10).innerText()).toContain('关于我.md')
+    const collapse = card.getByRole('button', { name: 'Collapse changed files' })
+    expect(await collapse.getAttribute('aria-expanded')).toBe('true')
+    // The collapse control is the card's last element after expansion.
+    expect(await card.evaluate(element => element.lastElementChild?.getAttribute('aria-expanded'))).toBe('true')
+    await collapse.click()
+    await expect.poll(() => rows.count()).toBe(3)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(0)
+
+    const geometry = await card.evaluate(element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth }))
+    expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 90_000)
+})

+ 1 - 1
apps/web/tests/produced-files.overlay.yml → apps/web/tests/changed-files.overlay.yml

@@ -1,4 +1,4 @@
-# The summary test asserts the native-folder action without launching it. Pin
+# The card test asserts the native folder and file 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

+ 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)
-})

+ 11 - 0
apps/web/tests/scaffold.ts

@@ -64,6 +64,7 @@ import {
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
+import { DEFAULT_SHADOW_EXCLUDES } from '@deepseek-ai/dsh-workspace-changes'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk,
@@ -596,6 +597,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       : [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
     { id: 'settings', config: { dshHome: harnessHome } },
     { id: 'credentials', config: { dshHome: harnessHome } },
+    // Turn change summaries snapshot the temp workspace into a shadow
+    // repository under the owned harness home; the scaffold's other in-workspace
+    // roots stay out of every snapshot so goldens depend only on scenario files.
+    {
+      id: 'workspace-changes',
+      config: {
+        dshHome: harnessHome,
+        shadowExcludes: [...DEFAULT_SHADOW_EXCLUDES, '.agents-home/', '.bundled-skills/', '.dsh-storages/'],
+      },
+    },
     // The shipped directory-picker row is the -auto chooser, which resolves
     // the interaction from the RUNNING host (display, SSH launch, bind). The
     // lane's goldens are interaction-specific (workspace-management drives

+ 1 - 1
apps/web/tsconfig.json

@@ -95,7 +95,7 @@
     "tests/agent-team-panel.e2e.ts",
     "tests/sidebar-right.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
-    "tests/produced-files.e2e.ts",
+    "tests/changed-files.e2e.ts",
     "tests/produced-file-mentions.e2e.ts",
     "tests/goal-bar.e2e.ts",
     "tests/goal-command-presentation.e2e.ts",

+ 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: b1dacada2a755361bf9e1e9d4a1ab61ee5a845c4
-config-catalog.zh.md: dca9c956c9451762cc8b301f3903077997671ef3
+config-catalog.md: bba110d61496fba2f69383f9375613fd369fad03
+config-catalog.zh.md: 01054e3dda0a36ab79cc00d1ee86ede2e4fc8e32

+ 24 - 0
docs/config-catalog.md

@@ -3438,6 +3438,30 @@ export interface Config {
 
 Source: [`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages/workflow/workflow-worker-thread/src/index.ts)
 
+<a id="deepseek-aidsh-workspace-changes"></a>
+
+## `@deepseek-ai/dsh-workspace-changes`
+
+Requires: `subprocess`
+
+```ts config-catalog
+/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+export interface Config {
+  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
+  dshHome?: string
+  /** 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 event; `total` still reports the complete count. */
+  maxFiles: number
+  /** `info/exclude` patterns for shadow repositories. */
+  shadowExcludes: string[]
+}
+```
+
+Source: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/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.

+ 24 - 0
docs/config-catalog.zh.md

@@ -3440,6 +3440,30 @@ export interface Config {
 
 来源:[`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages/workflow/workflow-worker-thread/src/index.ts)
 
+<a id="deepseek-aidsh-workspace-changes"></a>
+
+## `@deepseek-ai/dsh-workspace-changes`
+
+依赖: `subprocess`
+
+```ts config-catalog
+/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+export interface Config {
+  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
+  dshHome?: string
+  /** 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 event; `total` still reports the complete count. */
+  maxFiles: number
+  /** `info/exclude` patterns for shadow repositories. */
+  shadowExcludes: string[]
+}
+```
+
+来源: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/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: 31bcff64bdbd4caa24124f196beef86233f22538
-event-producer-consumer.zh.md: 239149009d0ade0fa2869989e32d2c41fad46c83
+event-producer-consumer.md: 180144f94eb1ce1fa538dc98c813c6c2b883c288
+event-producer-consumer.zh.md: 300a2fe2605f2451052d24c43e6c2c50b06aa996

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

@@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../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:391`](../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:391`](../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/fs/workspace-changes) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -49,8 +49,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `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), [`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/fs/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), [`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/fs/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) |
@@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../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:144`](../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), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../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), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/fs/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../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:189`](../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) |
 | `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` |

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

@@ -23,7 +23,7 @@
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../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:391`](../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:391`](../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/fs/workspace-changes) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -51,8 +51,8 @@
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `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), [`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/fs/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), [`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/fs/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) |
@@ -66,7 +66,7 @@
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../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:144`](../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), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../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), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/fs/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../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:189`](../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) |
 | `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` |

+ 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: 9e1f873b5690838c8a680ddf4ee3dd9c009cec80
-module-graph.zh.md: 81abb4bb4959ad72901bfda270e08dccd237c256
+module-graph.md: cd584a91ad18a3cb76dfb75212732fd86bb972c8
+module-graph.zh.md: 9727a6e78f15d08ce0f32545242c6e0dd957cebe

+ 7 - 0
docs/module-graph.md

@@ -58,6 +58,7 @@ flowchart TD
     pkg_tool_fs_search["tool-fs-search"]
     pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
+    pkg_workspace_changes["workspace-changes"]
   end
   subgraph group_skill["packages/skill"]
     pkg_skill["skill"]
@@ -747,6 +748,11 @@ flowchart TD
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
   pkg_tool_str_replace_editor --> pkg_tools
+  pkg_workspace_changes --> pkg_agent
+  pkg_workspace_changes --> pkg_home_paths
+  pkg_workspace_changes --> pkg_session
+  pkg_workspace_changes --> pkg_subprocess
+  pkg_workspace_changes --> pkg_tools
   pkg_tool_skill --> pkg_agent
   pkg_tool_skill --> pkg_llm
   pkg_tool_skill --> pkg_skill
@@ -1387,6 +1393,7 @@ flowchart TD
 | [`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) |
+| [`workspace-changes`](../packages/fs/workspace-changes) | `fs` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`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) |
 | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |

+ 7 - 0
docs/module-graph.zh.md

@@ -60,6 +60,7 @@ flowchart TD
     pkg_tool_fs_search["tool-fs-search"]
     pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
+    pkg_workspace_changes["workspace-changes"]
   end
   subgraph group_skill["packages/skill"]
     pkg_skill["skill"]
@@ -749,6 +750,11 @@ flowchart TD
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
   pkg_tool_str_replace_editor --> pkg_tools
+  pkg_workspace_changes --> pkg_agent
+  pkg_workspace_changes --> pkg_home_paths
+  pkg_workspace_changes --> pkg_session
+  pkg_workspace_changes --> pkg_subprocess
+  pkg_workspace_changes --> pkg_tools
   pkg_tool_skill --> pkg_agent
   pkg_tool_skill --> pkg_llm
   pkg_tool_skill --> pkg_skill
@@ -1389,6 +1395,7 @@ flowchart TD
 | [`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) |
+| [`workspace-changes`](../packages/fs/workspace-changes) | `fs` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`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) |
 | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`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: 7d6c9649628596b04dbe1d75c564a7f5965be940
-persistence-catalog.zh.md: c1736a1f5654f82ea9995b6389630648fc8185eb
+persistence-catalog.md: 3afb94a67096e005f9222e5ddff7470ccdc2c8d2
+persistence-catalog.zh.md: be01524413efa29888206cdf93a79b98ef97814b

+ 13 - 0
docs/persistence-catalog.md

@@ -1146,3 +1146,16 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/
 ```
 
 Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
+
+### `workspace/*`
+
+<a id="workspacechanges--log-only"></a>
+
+#### `workspace/changes` — log-only
+
+```ts persistence-catalog
+/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */
+'workspace/changes': WorkspaceChangesData
+```
+
+Source: [`packages/fs/workspace-changes/src/types.ts:36`](../packages/fs/workspace-changes/src/types.ts)

+ 13 - 0
docs/persistence-catalog.zh.md

@@ -1148,3 +1148,16 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 ```
 
 来源:[`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
+
+### `workspace/*`
+
+<a id="workspacechanges--log-only"></a>
+
+#### `workspace/changes` — 仅日志
+
+```ts persistence-catalog
+/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */
+'workspace/changes': WorkspaceChangesData
+```
+
+Source: [`packages/fs/workspace-changes/src/types.ts:36`](../packages/fs/workspace-changes/src/types.ts)

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

@@ -274,11 +274,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: 0894d4e0ef5e45876a58abea40cdad840e7dfacd
-README.zh.md: 8183ef33ca7e08bec2533eb2e21486809c94cedd
+README.md: b2cbec2db3ee1bd9907bbaad95061373fd13ed63
+README.zh.md: 58fccb9a54578a9570510d8eadb3eb5da70ce8e3

+ 1 - 1
packages/client/README.md

@@ -70,7 +70,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — |
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`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 host's OS chooser | — |

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

@@ -70,7 +70,7 @@ kind: "package-group"
 | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — |
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`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) | 驱动宿主 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: bb15db710decbc16a0974daf6bed555c3fb10815
+README.zh.md: a7db3227b5765eb303caf80e9337ee6b6d1354d7

+ 14 - 11
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,7 +25,7 @@ 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](../../fs/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 recorded summary — no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain.
 
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
@@ -34,9 +34,9 @@ The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files
 
 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 turn's latest `workspace/changes` event: 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 share the delivery cards' pending, acknowledged, and retryable-error states in place of the counts. 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 each Turn's latest validated `workspace/changes` event into `DeliverablesTurnData.changes`, 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. The Host reads the viewed Session header with the declaration or summary 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.
 
 </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](../../fs/workspace-changes/README.md) — the Host plugin that records 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.
 
@@ -95,9 +97,10 @@ 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.
+- **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

+ 14 - 11
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,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略项只显示为标签
+与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接
 
 <a id="explicit-deliveries"></a>
 ### 显式交付
@@ -34,9 +34,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
-### 该行
+### 改动文件卡片
 
-“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动
+卡片渲染本轮最新的 `workspace/changes` 事件:标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。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`,把 `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](../../fs/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 包。
 
@@ -95,9 +97,10 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用
+- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
-- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
+- **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。
+- **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 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.5-rc.2",
   "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:^",

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

@@ -0,0 +1,43 @@
+/** Validate recorded workspace changes and address their native-open actions. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceChangedFile, WorkspaceChangesData } from '@deepseek-ai/dsh-workspace-changes/types'
+
+/** 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'
+
+/**
+ * Validate one changed-file record read from a Session log.
+ * @param value - decoded durable data.
+ * @returns whether the record carries a path, a display path, and line counts.
+ */
+export function isChangedFile(value: unknown): value is WorkspaceChangedFile {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const { path, display, added, deleted, binary } = value as Record<string, unknown>
+  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 change summary before reading its turn or files.
+ * @param value - decoded durable event data.
+ * @returns whether the event identifies a turn, a complete file list, and the total count.
+ */
+export function isChangesData(value: unknown): value is WorkspaceChangesData {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const { turn, files, total } = value as Record<string, unknown>
+  return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1
+    && Number.isSafeInteger(total) && Array.isArray(files) && files.every(isChangedFile)
+}
+
+/**
+ * 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 event'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; } }

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

@@ -0,0 +1,113 @@
+/** 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, IconCodeOutline16 } 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 type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
+import { changedFileUrl } from '../changes.ts'
+import type { PresentedHost } from '../presented.ts'
+import type { PresentedOpenPhase } from './present-open.ts'
+import type { ChangesTurnData } from './turn-deliverables.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')
+
+/** Row copy for an open gesture; the changed-files card never reveals, so only open phases occur. */
+function rowStatus(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.opened' | 'presented.error' | 'presented.nativeUnavailable'; error: boolean } | undefined {
+  switch (phase) {
+    case undefined: return undefined
+    case 'opening': return { key: 'presented.opening', error: false }
+    case 'error': return { key: 'presented.error', error: true }
+    case 'nativeUnavailable': return { key: 'presented.nativeUnavailable', error: true }
+    default: return { key: 'presented.opened', error: false }
+  }
+}
+
+function totals(files: readonly WorkspaceChangedFile[]): { added: number; deleted: number } {
+  let added = 0
+  let deleted = 0
+  for (const file of files) {
+    added += file.added
+    deleted += file.deleted
+  }
+  return { added, deleted }
+}
+
+/**
+ * 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 }: {
+  changes: ChangesTurnData
+  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 sum = totals(changes.files)
+  const folderPhase = phases[changedFileUrl(sessionId, changes.seq, null)]
+  const folderStatus = folderPhase === 'opening' ? t('changes.folderOpening')
+    : folderPhase === 'opened' ? t('changes.folderOpened')
+      : folderPhase === undefined ? undefined : t('changes.folderError')
+  const summary = <>
+    <span className={css.tile}><IconCodeOutline16 /></span>
+    <span className={css.titles}>
+      <span className={css.title}>{t('changes.title', { count: String(changes.total) })}</span>
+      <span className={css.stat} role={folderStatus === undefined ? undefined : 'status'} data-error={folderPhase === 'error' || folderPhase === 'nativeUnavailable' ? true : undefined}>
+        {folderStatus ?? <>
+          <span className={css.added}>{t('changes.added', { count: GROUPED.format(sum.added) })}</span>
+          <span className={css.deleted}>{t('changes.deleted', { count: GROUPED.format(sum.deleted) })}</span>
+        </>}
+      </span>
+    </span>
+  </>
+  return <div className={css.card} data-changed-files>
+    {native
+      ? <button type="button" className={css.header} aria-label={t('changes.openFolder')}
+        disabled={folderPhase === 'opening'} 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 = rowStatus(phase)
+        return <li key={file.display}>
+          <button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
+            aria-label={t(native ? 'changes.openFile' : 'presented.previewButton', { name: file.display })}
+            disabled={phase === 'opening'}
+            onClick={() => { if (native) 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?.error ? true : undefined}>
+              {status !== undefined ? t(status.key)
+                : file.binary === true ? t('changes.binary')
+                  : <>
+                    <span className={css.added}>{t('changes.added', { count: GROUPED.format(file.added) })}</span>
+                    <span className={css.deleted}>{t('changes.deleted', { count: GROUPED.format(file.deleted) })}</span>
+                  </>}
+            </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; }

+ 18 - 14
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -1,18 +1,18 @@
-/** Existing changed-file chips and explicitly declared files for a closing turn. */
+/** The changed-files card 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 { ChangedFiles } from './ChangedFiles.tsx'
+import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts'
 import type { NS } from './locales.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
 
@@ -24,25 +24,26 @@ export interface DeliverablesInjected {
   }
   reloadPresentedHost: PresentedOpenController['loadHost']
   openPresented: PresentedOpenController['open']
+  openChanged: PresentedOpenController['openChanged']
 }
 
 /**
- * Claim turns containing modified paths or declared files.
+ * Claim turns with a recorded change summary or declared files.
  * @param owner - closing turn.
- * @returns matched files, or null for an empty turn.
+ * @returns matched changes 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.
+ * Render the changed-files card and default-application buttons for declared files.
  * @param props - matched 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, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
 } & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
   const [expanded, setExpanded] = useState(false)
@@ -54,13 +55,16 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope
     ? 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])
+  const { changes } = matched
   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={matched.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>
-  )
-}

+ 9 - 11
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'
@@ -24,14 +24,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']
 
@@ -55,6 +52,7 @@ export function apply(ctx: ClientContext): void {
         hooks: { presentedOpen: opener.state, presentedHost: opener.host },
         reloadPresentedHost: () => opener.loadHost(),
         openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
+        openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
       }),
     }, Deliverables),
   )

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

@@ -38,10 +38,19 @@ 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.folderOpened': '已请求打开文件夹',
+  'changes.folderError': '无法打开文件夹,点击重试',
 }
 
 /** English dictionary (same key set). */
@@ -79,10 +88,19 @@ 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.folderOpened': 'Requested 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' })

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

@@ -1,13 +1,16 @@
 /**
  * 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 type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
+import { isChangesData } from '../changes.ts'
 import { basename, isPresentedData, isPresentedFile } from '../presented.ts'
 
 /** A declared file with its authorized open coordinates. */
@@ -21,15 +24,23 @@ interface ProducedPath {
   readonly path: string
 }
 
+/** The latest recorded change summary of one Turn with its open coordinates. */
+export interface ChangesTurnData {
+  readonly seq: number
+  readonly files: readonly WorkspaceChangedFile[]
+  readonly total: 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 +171,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 isChangesData(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 +182,10 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     return { turn: match.event.data.turn, calls: new Map(), produced: [] }
   },
   update: (context, match) => {
+    if (match.event.type === 'workspace/changes') {
+      const { files, total } = match.event.data
+      return { ...context.state, changes: { seq: match.event.seq, files, total } }
+    }
     if (match.event.type === 'deliverables/presented') {
       const { files } = match.event.data
       const seq = match.event.seq
@@ -204,16 +220,31 @@ 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 recorded change summary when it lists at least one file.
+ * @param owner - closing turn.
+ * @returns the latest summary, or null when the Host recorded none or an empty one.
+ */
+export function changesForClosing(owner: TurnTailOwnerProps): ChangesTurnData | null {
+  const changes = owner.turn.data.get('deliverables')?.changes
+  return changes === undefined || changes.files.length === 0 ? null : changes
+}
+
 /**
  * Select the latest declaration of each path before the closing reply.
  * @param owner - closing turn and sequence.

+ 123 - 41
packages/client/ui-deliverables/src/present-open.ts

@@ -1,4 +1,5 @@
-/** Open declared source files verified by the viewed Session's filesystem. */
+/** 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,10 +9,13 @@ 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, isChangesData } 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.
+ * Register native opening inside Connection's authentication fence: desktop
+ * metadata, declared-file actions, and changed-file or folder opening.
  * @param ctx - Session lookup, native opener, and route lifetime.
  */
 export function registerPresentOpen(ctx: Context): void {
@@ -26,19 +30,63 @@ export function registerPresentOpen(ctx: Context): void {
     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 +94,74 @@ 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
+}
+
+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 {
+    const read = await readTarget(ctx, request, id, seq)
+    if (read instanceof Response) return read
+    const { target, session } = read
+    const changes = target.type === 'workspace/changes' && isChangesData(target.data) ? target.data : undefined
+    if (changes === undefined) return new Response('Changed files not found in this Session event.', { status: 404 })
+    const workspaceRoot = session.cwd ?? ctx.sandboxPolicy.workspaceRoot
+    request.signal.throwIfAborted()
+    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 Session event.', { 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) })
   }
 }

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

@@ -0,0 +1,146 @@
+/** Changed-file and common-folder native opens resolve the viewed Session's current workspace. */
+import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } 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 type { SessionEvent } 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 } 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, CHANGES_OPEN_PATH, isChangedFile, isChangesData } 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 = { turn: 1, total: 3, 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())
+  const session: { cwd?: string } = { cwd }
+  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) => {
+    if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
+    if (request.seq !== 9) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
+    return { session, target: { type: 'workspace/changes', data } as SessionEvent }
+  })
+  ctx.provide('sessionQuery', { readEvent } as never)
+  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'], 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' }))
+  return { root, cwd, ctx, data, session, readEvent, open, opener, outside }
+}
+
+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))
+    expect(commonChangedFolder('/w', [changed('a/b/c.ts'), changed('a/d.ts'), changed('/x/y.ts')])).toBe('/w/a')
+    expect(commonChangedFolder('/w', [changed('/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, other events, malformed data, unknown indices, and missing files', async () => {
+    const { open, readEvent, session, opener, cwd, data } = 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)
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
+    expect((await open()).status).toBe(404)
+    for (const bad of [null, { turn: 0, files: [], total: 0 }, { turn: 1, files: null, total: 0 }, { turn: 1, files: [{ path: 'a' }], total: 1 }, { turn: 1, files: [], total: 1.5 }]) {
+      readEvent.mockResolvedValueOnce({ session, target: { type: 'workspace/changes', data: bad } as unknown as SessionEvent })
+      expect((await open()).status).toBe(404)
+    }
+    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('uses the deployment workspace root when the viewed Session has no cwd', async () => {
+    const { session, open, opener, cwd } = await fixture()
+    delete session.cwd
+    expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
+    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, 'src')))
+  })
+
+  it('validates recorded change records', () => {
+    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(isChangesData({ turn: 1, total: 0, files: [] })).toBe(true)
+    expect(isChangesData({ turn: '1', total: 0, files: [] })).toBe(false)
+    expect(isChangesData([])).toBe(false)
+  })
+})

+ 144 - 71
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'
@@ -23,10 +24,9 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
 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 ChangesTurnData, type DeliverablesTurnData,
 } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
@@ -42,6 +42,7 @@ function openProps(controller = new PresentedOpenController()) {
     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 +425,135 @@ 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, files: ReturnType<typeof changedFile>[], turn = 1, total = files.length) =>
+  at(seq, 'workspace/changes', { turn, files, total, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) } })
 
-    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('recorded changes Turn data', () => {
+  it('keeps the latest valid summary of the turn and ignores malformed or empty ones for the card', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      changesEvent(2, [changedFile('old.ts')]),
+      at(3, 'workspace/changes', { turn: 1, files: [{ path: 'x' }], total: 1 }),
+      at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+      changesEvent(5, [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })], 1, 7),
+      at(6, 'turn/start', { turn: 2 }),
+      changesEvent(7, [], 2),
+    ])
+    const owner = tailOwner(deliverablesOf(value), 4)
+    expect(changesForClosing(owner)).toEqual({ seq: 5, total: 7, files: [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })] })
+    expect(selectDeliverables(owner)).toEqual({ changes: changesForClosing(owner), 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, [changedFile('a.ts')])])
+    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: ChangesTurnData = { seq: 5, files, total: 11 }
+
+  function renderCard(controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }) {
+    const props = openProps(controller)
+    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('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 } = 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()
+    expect(view.getByText(en['changes.folderOpened'])).toBeTruthy()
+    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)
+    expect(pending.getByText(en['presented.opened'])).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: ChangesTurnData = { seq: 5, files: files.slice(0, 2), total: 2 }
+    const { view } = renderCard(new PresentedOpenController(), en, { changes: short, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] })
+    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')
   })
 })
 
@@ -572,6 +650,8 @@ describe('plugin registration', () => {
     expect(face.hooks.presentedHost.getSnapshot()).toBeNull()
     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 +716,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 +728,23 @@ 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, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 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()
+  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 +754,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 +769,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 +781,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 +792,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 +804,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, total: 1, files: [{ path: 'a.ts', display: 'a.ts', added: 1, deleted: 0 }] }, 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 }))

+ 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)

+ 4 - 0
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"
   ],
@@ -53,6 +54,9 @@
     {
       "path": "../../fs/tool-present"
     },
+    {
+      "path": "../../fs/workspace-changes"
+    },
     {
       "path": "../../util/workspace-path"
     }

+ 4 - 0
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"
   ],
@@ -32,6 +33,9 @@
     {
       "path": "../../fs/tool-present"
     },
+    {
+      "path": "../../fs/workspace-changes"
+    },
     {
       "path": "../../api/session-controller/tsconfig.host.json"
     },

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

@@ -76,4 +76,5 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'turn/start',
   'user/message',
   'web/deepseek-search-llm-request',
+  'workspace/changes',
 ])

+ 2 - 2
packages/fs/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/fs/README.md
-README.md: dd3e6f1fab3a8439dd8e2ff15f854e2872549eef
-README.zh.md: e4139d05c5bbd08c14bce80c0d2f363e92eeaaf4
+README.md: e0b6e1af4c38acf0896aabbf37af5a3803fb34ae
+README.zh.md: b9df359943f225e4b9e95ca4a95c013eeb078bf1

+ 1 - 0
packages/fs/README.md

@@ -35,6 +35,7 @@ Eight packages plus the remote sibling `fs-e2b` play the filesystem roles; the s
 | [`tool-fs-search/`](tool-fs-search/README.md) | Model-facing `glob` and `grep` discovery tools backed by the packaged ripgrep binary | registers on `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Standalone `str_replace_editor` tool: `view`, `create`, `str_replace`, and `insert` over `ctx.fs` | registers on `ctx.tools` |
 | [`tool-present/`](tool-present/README.md) | Explicit immutable snapshots of delivered files | registers on `ctx.tools` |
+| [`workspace-changes/`](workspace-changes/README.md) | Per-turn changed-file summaries recorded from git working-tree snapshots | listens to `session/event`, appends `workspace/changes` |
 
 The policy is a plugin, not a service the tools inject: removing it leaves the bare provider's unconditional mutation behavior instead of breaking the tools. The mode fence in `fs-sandbox` and the read-before-edit gate compose. `tool-fs-search` deliberately does not extend the provider contract — search is a process-backed ripgrep workflow, so filesystem backends stay free of a universal search API.
 

+ 1 - 0
packages/fs/README.zh.md

@@ -35,6 +35,7 @@ kind: "package-group"
 | [`tool-fs-search/`](tool-fs-search/README.zh.md) | 由打包 ripgrep 二进制支持的面向模型 `glob` 与 `grep` 发现工具 | 注册到 `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.zh.md) | 独立的 `str_replace_editor` 工具:基于 `ctx.fs` 的 `view`、`create`、`str_replace` 与 `insert` | 注册到 `ctx.tools` |
 | [`tool-present/`](tool-present/README.zh.md) | 显式保存交付文件的不可变快照 | 注册到 `ctx.tools` |
+| [`workspace-changes/`](workspace-changes/README.zh.md) | 由 git 工作树快照记录的每轮改动文件摘要 | 监听 `session/event`,追加 `workspace/changes` |
 
 策略是插件,不是工具注入的服务:移除它会留下裸提供方的无条件变更行为,而不会破坏工具。`fs-sandbox` 的模式围栏与编辑前读取门禁可以组合。`tool-fs-search` 有意不扩展提供方约定——搜索是由进程支持的 ripgrep 工作流,因此文件系统后端无需承担通用搜索 API。
 

+ 6 - 0
packages/fs/workspace-changes/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/fs/workspace-changes/README.md
+README.md: c5b83477ad986e8c4cecf403285ad8e1a7106fb7
+README.zh.md: 75d96e9a769412946eb788874564cc2f7b85bdf7

+ 101 - 0
packages/fs/workspace-changes/README.md

@@ -0,0 +1,101 @@
+---
+description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository tiers, and coverage rules."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-workspace-changes
+
+English | [中文](README.zh.md)
+
+## Summary
+
+This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Web changed-files card renders the event; the model never sees it.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+The shipped Web bundle mounts this plugin. Mount it in any composition with the `subprocess` capability and a git executable on the Host:
+
+```yaml
+- name: '@deepseek-ai/dsh-workspace-changes'
+  config:
+    maxFiles: 500
+```
+
+| Field | Default | Meaning |
+|---|---|---|
+| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds shadow repositories |
+| `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned |
+| `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record |
+| `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count |
+| `shadowExcludes` | `.git/`, `.dsh/`, `node_modules/`, build and cache directories | `info/exclude` patterns for shadow repositories |
+
+Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. A working directory inside a git repository snapshots into that repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. A working directory outside any repository snapshots into a shadow repository under the Harness home whose work tree is the working directory itself; the `shadowExcludes` patterns replace the missing `.gitignore`, and a Harness home inside the working directory is excluded automatically. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once.
+
+Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository or shadow work tree. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded.
+
+Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The event also carries the two snapshot tree ids.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository or initializes the shadow one, then `add --all` into a temporary index seeded from the persisted index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. A shadow repository keeps its refreshed index for the next snapshot; a real repository's index is only read.
+
+Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work.
+
+**Runtime invariant:** No companion is published. Event listeners are effect-owned, the Session log owns the recorded summaries, and the git object store owns snapshot trees; no independent observation can diverge from them.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that renders this event and opens its files.
+- [Subprocess capability](../../subprocess/README.md) — the seam git runs through.
+- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, and rejected alternatives.
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the recorder appends a log-only `workspace/changes` event that only clients read and registers nothing model-facing.
+
+#### KV Cache effect
+
+Nothing here enters a model request, so provider cache reuse is unaffected.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not.
+- Edits the user makes during a turn are attributed to that turn.
+- Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only.
+- Windows paths keep native separators in `path`; `display` is always slash-separated.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 101 - 0
packages/fs/workspace-changes/README.zh.md

@@ -0,0 +1,101 @@
+---
+description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库分档与覆盖规则。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-workspace-changes
+
+[English](README.md) | 中文
+
+## 概述
+
+本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Web 的改动文件卡片渲染该事件;模型看不到它。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+正式提供的 Web bundle 挂载本插件。任何具备 `subprocess` 能力且 Host 上有 git 可执行文件的组合都可以挂载它:
+
+```yaml
+- name: '@deepseek-ai/dsh-workspace-changes'
+  config:
+    maxFiles: 500
+```
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录存放影子仓库 |
+| `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 |
+| `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
+| `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 |
+| `shadowExcludes` | `.git/`、`.dsh/`、`node_modules/`、构建与缓存目录 | 影子仓库的 `info/exclude` 模式 |
+
+每个有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。工作目录位于 git 仓库内时,快照通过私有 index 写入该仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录不在任何仓库内时,快照写入 Harness home 下的影子仓库,其工作树就是该工作目录;`shadowExcludes` 模式代替缺失的 `.gitignore`,位于工作目录内的 Harness home 会被自动排除。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
+
+文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库或影子工作树之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
+
+每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库或初始化影子仓库,然后以持久 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。影子仓库保留刷新后的 index 供下次快照使用;真实仓库的 index 只读取。
+
+git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。
+
+**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,Session 日志拥有记录的摘要,git 对象库拥有快照树;没有独立观察会与它们分歧。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。
+- [子进程能力](../../subprocess/README.zh.md)——git 运行所经过的接缝。
+- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则与被否决的备选方案。
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为记录器只追加一条仅写日志、只有客户端读取的 `workspace/changes` 事件,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+这里的内容不会进入模型请求,因此不影响提供方缓存复用。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。
+- 用户在轮次进行中自己做的编辑会被算到该轮。
+- 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。
+- Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 58 - 0
packages/fs/workspace-changes/package.json

@@ -0,0 +1,58 @@
+{
+  "name": "@deepseek-ai/dsh-workspace-changes",
+  "description": "Per-turn workspace file changes recorded from git working-tree snapshots for the DeepSeek Harness",
+  "version": "0.1.5-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/fs/workspace-changes"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "MIT",
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^",
+    "diff": "^9.0.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^"
+  }
+}

+ 213 - 0
packages/fs/workspace-changes/src/git.ts

@@ -0,0 +1,213 @@
+/** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
+import { createHash } from 'node:crypto'
+import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, relative } from 'node:path'
+import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
+import { parseNumstat, type NumstatEntry } from './numstat.ts'
+import { isInside, toPosix } from './paths.ts'
+
+/** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
+const TERMINATE_GRACE_MS = 2_000
+/** Retained stderr tail for diagnostics. */
+const STDERR_TAIL_BYTES = 16 * 1024
+
+/** Settled git command facts; a nonzero exit is a result, not an exception. */
+export interface GitRunResult {
+  exitCode: number | null
+  stdout: string
+  stderr: string
+  /** True when stdout exceeded the output cap and lost its head. */
+  truncated: boolean
+}
+
+/** Per-command spawn facts. */
+export interface GitRunOptions {
+  cwd: string
+  env?: Readonly<Record<string, string>> | undefined
+  stdin?: string | undefined
+  signal: AbortSignal
+}
+
+/** Bounds every git command runs under. */
+export interface GitLimits {
+  /** Milliseconds before a command is terminated. */
+  timeoutMs: number
+  /** In-memory stdout cap in bytes. */
+  outputMaxBytes: number
+}
+
+/** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */
+export class GitRunner {
+  constructor(
+    private readonly subprocess: SubprocessRuntime,
+    private readonly executable: string,
+    private readonly limits: GitLimits,
+  ) {}
+
+  /**
+   * Run `git <args>` to completion.
+   * @param args - git arguments; never shell-interpreted.
+   * @param options - working directory, extra environment, stdin data, and cancellation.
+   * @returns exit facts and collected output.
+   * @throws when the command times out, is aborted, or cannot spawn.
+   */
+  async run(args: readonly string[], options: GitRunOptions): Promise<GitRunResult> {
+    const timeout = AbortSignal.timeout(this.limits.timeoutMs)
+    const signal = AbortSignal.any([options.signal, timeout])
+    const handle = this.subprocess.spawn({
+      argv: [this.executable, ...args],
+      cwd: options.cwd,
+      stdio: {
+        stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
+        stdout: { maxBytes: this.limits.outputMaxBytes },
+        stderr: { maxBytes: STDERR_TAIL_BYTES },
+      },
+      graceMs: TERMINATE_GRACE_MS,
+      signal,
+      env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env },
+    })
+    const outcome = await handle.done
+    if (signal.aborted) {
+      throw new Error(`git ${args.join(' ')} ${timeout.aborted ? `timed out after ${this.limits.timeoutMs}ms` : 'was aborted'}`)
+    }
+    /* v8 ignore start -- collect-mode stdio always yields both readers. */
+    const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
+    const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
+    /* v8 ignore stop */
+    return { exitCode: outcome.exitCode, stdout: stdout.text, stderr, truncated: stdout.lossy }
+  }
+}
+
+/**
+ * Reject a failed command with its stderr.
+ * @param result - settled command facts.
+ * @param what - command description for the error message.
+ * @returns the same result when it exited zero.
+ */
+function ok(result: GitRunResult, what: string): GitRunResult {
+  if (result.exitCode !== 0) throw new Error(`${what} failed: ${result.stderr.trim()}`)
+  return result
+}
+
+/**
+ * The git repository a Session working directory snapshots into: the
+ * enclosing repository when one exists, otherwise a shadow repository whose
+ * git directory lives under the Harness home while its work tree is the
+ * working directory itself.
+ */
+export interface GitWorkspace {
+  kind: 'repository' | 'shadow'
+  /** Work-tree root: the repository top level, or the working directory for a shadow. */
+  root: string
+  /** Absolute git directory holding the index and objects. */
+  gitDir: string
+  /** Environment that addresses the repository for every command. */
+  env: Readonly<Record<string, string>>
+}
+
+/** Where and how shadow repositories are created. */
+export interface ShadowRepositoryOptions {
+  /** Directory that holds one shadow git directory per working directory. */
+  home: string
+  /** `info/exclude` patterns written on every snapshot. */
+  excludes: readonly string[]
+}
+
+/**
+ * Locate the repository for a working directory, creating the shadow
+ * repository when the directory is not inside one.
+ * @param git - command runner.
+ * @param cwd - absolute Session working directory.
+ * @param shadow - shadow repository placement and excludes.
+ * @param signal - cancellation.
+ * @returns the addressed repository.
+ */
+export async function locateGitWorkspace(
+  git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal,
+): Promise<GitWorkspace> {
+  const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal })
+  if (found.exitCode === 0) {
+    const [root, gitDir] = found.stdout.split('\n') as [string, string]
+    return { kind: 'repository', root, gitDir, env: {} }
+  }
+  const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16))
+  const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd }
+  await mkdir(gitDir, { recursive: true })
+  ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`)
+  const excludes = [...shadow.excludes]
+  if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`)
+  await mkdir(join(gitDir, 'info'), { recursive: true })
+  await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`)
+  return { kind: 'shadow', root: cwd, gitDir, env }
+}
+
+/**
+ * Write the complete work tree, including untracked and modified files but
+ * not ignored ones, as a tree object through a private index. The
+ * repository's own index seeds the stat cache and is never modified, and an
+ * in-progress merge keeps its unmerged entries; a shadow repository keeps the
+ * refreshed index for its next snapshot.
+ * @param git - command runner.
+ * @param workspace - addressed repository.
+ * @param signal - cancellation.
+ * @returns the tree object id.
+ */
+export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise<string> {
+  const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-'))
+  try {
+    const index = join(scratch, 'index')
+    const persisted = join(workspace.gitDir, 'index')
+    // A missing persisted index (a fresh shadow repository) starts from scratch.
+    await copyFile(persisted, index).then(() => undefined, () => undefined)
+    const env = { ...workspace.env, GIT_INDEX_FILE: index }
+    ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
+    const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
+    if (workspace.kind === 'shadow') await rename(index, persisted)
+    return tree
+  } finally {
+    await rm(scratch, { recursive: true, force: true })
+  }
+}
+
+/**
+ * Per-file line counts between two snapshot trees, with renames detected.
+ * @param git - command runner.
+ * @param workspace - addressed repository.
+ * @param before - turn-start tree id.
+ * @param after - turn-end tree id.
+ * @param signal - cancellation.
+ * @returns changed files relative to the work-tree root.
+ * @throws when git fails or the output exceeded the cap.
+ */
+export async function diffTrees(
+  git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal,
+): Promise<NumstatEntry[]> {
+  if (before === after) return []
+  const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
+    cwd: workspace.root, env: workspace.env, signal,
+  }), 'git diff-tree')
+  if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap')
+  return parseNumstat(result.stdout)
+}
+
+/**
+ * The subset of work-tree paths that the repository ignores. Tracked files
+ * are never reported, so a tracked file matching an ignore pattern still
+ * counts as covered by snapshots.
+ * @param git - command runner.
+ * @param workspace - addressed repository.
+ * @param paths - slash-separated paths relative to the work-tree root.
+ * @param signal - cancellation.
+ * @returns the ignored members of `paths`.
+ */
+export async function ignoredPaths(
+  git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal,
+): Promise<Set<string>> {
+  if (paths.length === 0) return new Set()
+  const result = await git.run(['check-ignore', '-z', '--stdin'], {
+    cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
+  })
+  if (result.exitCode === 1) return new Set()
+  return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''))
+}

+ 149 - 0
packages/fs/workspace-changes/src/index.ts

@@ -0,0 +1,149 @@
+/**
+ * Records the files each top-level turn changed as a durable `workspace/changes`
+ * Session event, derived from git working-tree snapshots taken at turn start
+ * and turn end plus the hunks file tools persist for paths git does not cover.
+ */
+import { realpathSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
+import type {} from '@deepseek-ai/dsh-agent'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-subprocess'
+import type {} from '@deepseek-ai/dsh-tools'
+import { GitRunner } from './git.ts'
+import { temporaryRoots } from './paths.ts'
+import { TurnRecorder } from './recorder.ts'
+
+export type { WorkspaceChangedFile, WorkspaceChangesData } from './types.ts'
+
+/** Stable Loader identity. */
+export const name = 'workspace-changes'
+
+/** Services used to run git and observe turns. */
+export const inject = ['subprocess']
+
+/** Directories excluded from shadow repositories, where no `.gitignore` exists. */
+export const DEFAULT_SHADOW_EXCLUDES: readonly string[] = [
+  '.git/', '.dsh/', '.DS_Store', 'node_modules/', '.venv/', 'venv/', '__pycache__/', '.pytest_cache/', '.mypy_cache/',
+  'dist/', 'build/', 'out/', 'target/', '.cache/', '.next/', '.nuxt/', '.turbo/', 'coverage/',
+]
+
+/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */
+export interface Config {
+  /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */
+  dshHome?: string
+  /** 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 event; `total` still reports the complete count. */
+  maxFiles: number
+  /** `info/exclude` patterns for shadow repositories. */
+  shadowExcludes: string[]
+}
+
+/** Schemastery validation for {@link Config}. */
+export const Config: z<Config> = z.object({
+  dshHome: z.string(),
+  timeoutMs: z.number().default(30_000),
+  outputMaxBytes: z.number().default(8 * 1024 * 1024),
+  maxFiles: z.number().default(500),
+  shadowExcludes: z.array(z.string()).default([...DEFAULT_SHADOW_EXCLUDES]),
+})
+
+function eligible(session: Session): string | undefined {
+  const { cwd, origin, delegationDepth } = session.header
+  return origin === 'subagent' || (delegationDepth ?? 0) > 0 ? undefined : cwd
+}
+
+/**
+ * Resolve the git executable once. On macOS the Xcode stub at `/usr/bin/git`
+ * opens an installer dialog instead of running, so it counts as absent until
+ * developer tools are selected.
+ * @param ctx - subprocess capability.
+ * @param signal - plugin lifetime.
+ * @returns the executable path, or null when git is unavailable.
+ */
+async function resolveGit(ctx: Context, signal: AbortSignal): Promise<string | null> {
+  let executable: string
+  try {
+    executable = await ctx.subprocess.resolveExecutable('git', undefined, signal)
+  } catch {
+    return null
+  }
+  if (process.platform !== 'darwin' || executable !== '/usr/bin/git') return executable
+  const probe = ctx.subprocess.spawn({
+    argv: ['/usr/bin/xcode-select', '-p'], cwd: homedir(),
+    stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 1_000, signal,
+  })
+  const outcome = await probe.done.catch(() => ({ exitCode: null }))
+  return outcome.exitCode === 0 ? executable : null
+}
+
+/**
+ * Observe top-level turns of every Session with a working directory and append
+ * their change summaries.
+ * @param ctx - host context with `subprocess`.
+ * @param config - validated bounds and placement.
+ */
+export function apply(ctx: Context, config: Config): void {
+  for (const [field, value] of [['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles]] as const) {
+    if (!Number.isSafeInteger(value) || value < 1) throw new Error(`workspace-changes requires a positive integer ${field}`)
+  }
+  const lifetime = new AbortController()
+  const recorders = new Map<Session, TurnRecorder>()
+  ctx.effect(() => () => {
+    lifetime.abort()
+    for (const recorder of recorders.values()) recorder.dispose()
+    recorders.clear()
+  })
+  const shadow = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), excludes: config.shadowExcludes }
+  const roots = temporaryRoots()
+  const home = realpathSync.native(homedir())
+  let runner: Promise<GitRunner | null> | undefined
+  const gitRunner = (): Promise<GitRunner | null> => {
+    runner ??= resolveGit(ctx, lifetime.signal).then((executable) => {
+      if (executable === null) {
+        ctx.logger.info('workspace-changes: git is unavailable; turn file changes are not recorded')
+        return null
+      }
+      return new GitRunner(ctx.subprocess, executable, { timeoutMs: config.timeoutMs, outputMaxBytes: config.outputMaxBytes })
+    })
+    return runner
+  }
+  const recorderFor = (session: Session, cwd: string): TurnRecorder => {
+    let recorder = recorders.get(session)
+    if (recorder === undefined) {
+      recorder = new TurnRecorder(session, cwd, {
+        git: gitRunner(), shadow, home, temporaryRoots: roots, maxFiles: config.maxFiles,
+        warn: (message) => { ctx.logger.warn(message) },
+      })
+      recorders.set(session, recorder)
+    }
+    return recorder
+  }
+  ctx.on('session/event', (session, event) => {
+    if (event.type === 'turn/start') {
+      const cwd = eligible(session)
+      if (cwd !== undefined) recorderFor(session, cwd).start(event.data.turn)
+      return
+    }
+    if (event.type === 'tool/result') recorders.get(session)?.observe(event)
+    else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn)
+  })
+  ctx.on('session/disposed', (session) => {
+    recorders.get(session)?.dispose()
+    recorders.delete(session)
+  })
+  ctx.on('agent/turn-stopping', async ({ agent, turn }) => {
+    await recorders.get(agent.session)?.stopping(turn)
+  })
+  ctx.on('tools/pre-execute', async (exec, next) => {
+    const session = exec.agent?.session
+    if (session !== undefined) await recorders.get(session)?.settled()
+    return next()
+  })
+}

+ 81 - 0
packages/fs/workspace-changes/src/numstat.ts

@@ -0,0 +1,81 @@
+/** Line-count derivation from `git diff-tree --numstat -z` output and from recorded file-tool hunks. */
+import { structuredPatch } from 'diff'
+import type { FileDiff } from '@deepseek-ai/dsh-tools'
+
+/** One `--numstat` record; `path` is slash-separated and relative to the repository root. */
+export interface NumstatEntry {
+  path: string
+  added: number
+  deleted: number
+  binary: boolean
+}
+
+/**
+ * Parse NUL-terminated numstat records. A rename record carries an empty path
+ * followed by the old and new paths; only the new path is kept.
+ * @param output - complete stdout of `git diff-tree -r -M -z --numstat`.
+ * @returns records in git's output order.
+ * @throws when a record is malformed, which indicates truncated output.
+ */
+export function parseNumstat(output: string): NumstatEntry[] {
+  const queue = output.split('\0')
+  if (queue.at(-1) !== '') throw new Error('numstat output is not NUL-terminated')
+  queue.pop()
+  const entries: NumstatEntry[] = []
+  while (queue.length > 0) {
+    const record = queue.shift() as string
+    const [added, deleted, path] = record.split('\t')
+    if (added === undefined || deleted === undefined || path === undefined) throw new Error(`malformed numstat record: ${record}`)
+    let target = path
+    if (target === '') {
+      queue.shift()
+      const renamed = queue.shift()
+      if (renamed === undefined) throw new Error('malformed numstat rename record')
+      target = renamed
+    }
+    const binary = added === '-'
+    entries.push({ path: target, added: binary ? 0 : Number(added), deleted: binary ? 0 : Number(deleted), binary })
+  }
+  return entries
+}
+
+/**
+ * Sum the added and deleted lines over recorded hunks. Context lines appear on
+ * both sides of a hunk and cancel out; a hunk without prior text counts every
+ * line as added.
+ * @param diffs - the applied hunks a file tool persisted with its result.
+ * @returns line totals for one file.
+ */
+export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; deleted: number } {
+  let added = 0
+  let deleted = 0
+  for (const diff of diffs) {
+    const patch = structuredPatch('', '', diff.oldText ?? '', diff.newText)
+    for (const hunk of patch.hunks) {
+      for (const line of hunk.lines) {
+        if (line.startsWith('+')) added += 1
+        else if (line.startsWith('-')) deleted += 1
+      }
+    }
+  }
+  return { added, deleted }
+}
+
+/**
+ * Narrow a tool result's opaque `meta` to the file-tool hunk list.
+ * @param meta - persisted result metadata.
+ * @returns the hunks, or undefined when the metadata carries none.
+ */
+export function fileDiffsOf(meta: unknown): FileDiff[] | undefined {
+  if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
+  const { diffs } = meta as Record<string, unknown>
+  if (!Array.isArray(diffs) || diffs.length === 0) return undefined
+  const out: FileDiff[] = []
+  for (const value of diffs) {
+    if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
+    const { path, oldText, newText } = value as Record<string, unknown>
+    if (typeof path !== 'string' || (oldText !== null && typeof oldText !== 'string') || typeof newText !== 'string') return undefined
+    out.push({ path, oldText, newText })
+  }
+  return out
+}

+ 78 - 0
packages/fs/workspace-changes/src/paths.ts

@@ -0,0 +1,78 @@
+/** Path classification and display forms for changed files. */
+import { realpathSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+
+/** Slash-separated form of a native relative path. */
+export function toPosix(path: string): string {
+  return path.split(sep).join('/')
+}
+
+/** Whether `path` is `root` or lies under it. */
+export function isInside(root: string, path: string): boolean {
+  const rel = relative(root, path)
+  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
+}
+
+/**
+ * Canonical spellings of the temporary directories a workspace-write sandbox
+ * grants: the host `/tmp` and the platform temp area, each also in its
+ * symlink-resolved form so `/tmp` and `/private/tmp` match alike.
+ * @param candidates - directories to canonicalize.
+ * @returns absolute directory paths.
+ */
+export function temporaryRoots(candidates: readonly string[] = ['/tmp', tmpdir()]): string[] {
+  const roots = new Set<string>()
+  for (const root of candidates) {
+    roots.add(root)
+    try {
+      roots.add(realpathSync.native(root))
+    } catch {
+      // A missing temporary root matches nothing.
+    }
+  }
+  return [...roots]
+}
+
+/**
+ * Whether a file lives under a temporary root, where the model keeps scratch work.
+ * @param path - absolute file path.
+ * @param roots - {@link temporaryRoots}.
+ * @returns true for scratch paths that never enter the change summary.
+ */
+export function isTemporaryPath(path: string, roots: readonly string[]): boolean {
+  return roots.some(root => isInside(root, path))
+}
+
+/**
+ * Resolve a file-tool path against the Session working directory.
+ * @param cwd - absolute Session working directory.
+ * @param path - model-facing path, relative or absolute.
+ * @returns the absolute native path.
+ */
+export function absolutePathOf(cwd: string, path: string): string {
+  return resolve(cwd, path)
+}
+
+/** Sort key and label rules; see `WorkspaceChangedFile.display`. */
+export function displayPathOf(absolute: string, cwd: string, root: string, home: string): string {
+  const rel = relative(cwd, absolute)
+  if (!rel.startsWith('..') && !isAbsolute(rel)) return toPosix(rel)
+  if (isInside(root, absolute)) return toPosix(rel)
+  if (home !== '' && isInside(home, absolute)) return `~/${toPosix(relative(home, absolute))}`
+  return toPosix(absolute)
+}
+
+/** The durable `path` field: relative inside the working directory, absolute elsewhere. */
+export function durablePathOf(absolute: string, cwd: string): string {
+  const rel = relative(cwd, absolute)
+  return !rel.startsWith('..') && !isAbsolute(rel) ? toPosix(rel) : absolute
+}
+
+/**
+ * Code-unit order of display paths, which places `../` and absolute paths
+ * before letters and matches git's own listing order for relative paths.
+ */
+export function compareDisplay(a: { display: string }, b: { display: string }): number {
+  return a.display < b.display ? -1 : a.display > b.display ? 1 : 0
+}

+ 188 - 0
packages/fs/workspace-changes/src/recorder.ts

@@ -0,0 +1,188 @@
+/** Per-Session turn recorder: snapshot at turn start, diff and append at turn end. */
+import { realpath } from 'node:fs/promises'
+import { relative } from 'node:path'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { FileDiff } from '@deepseek-ai/dsh-tools'
+import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ShadowRepositoryOptions } from './git.ts'
+import { fileDiffsOf, hunkLineCounts } from './numstat.ts'
+import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts'
+import type { WorkspaceChangedFile } from './types.ts'
+
+/** Facts shared by every recorder of one plugin instance. */
+export interface RecorderEnvironment {
+  /** Resolves to the runner, or null when git is unavailable and no turn records anything. */
+  git: Promise<GitRunner | null>
+  shadow: ShadowRepositoryOptions
+  /** Canonical absolute home directory abbreviated as `~` in display paths. */
+  home: string
+  /** Temporary roots whose files never enter a summary. */
+  temporaryRoots: readonly string[]
+  /** Maximum files carried by one event. */
+  maxFiles: number
+  /** Failure reporter; a failed turn records nothing and the next turn retries. */
+  warn: (message: string) => void
+}
+
+interface Baseline { git: GitRunner; workspace: GitWorkspace; tree: string; cwd: string }
+
+/** Symlink-resolved path when the target exists, otherwise the lexical path. */
+async function canonicalPath(path: string): Promise<string> {
+  try {
+    return await realpath(path)
+  } catch {
+    // A deleted or unreadable target keeps its lexical spelling.
+    return path
+  }
+}
+
+/**
+ * Serializes one Session's git work: the turn-start snapshot, the turn-end
+ * snapshot with its diff, and the appended `workspace/changes` event. Tool
+ * execution waits for pending work so a snapshot never races a mutation.
+ */
+export class TurnRecorder {
+  private chain: Promise<void> = Promise.resolve()
+  private baseline: Baseline | null = null
+  private turn = 0
+  /** File-tool hunks by their model-facing path; canonicalized when the record is built. */
+  private hunks = new Map<string, FileDiff[]>()
+  private lastToolResultSeq = -1
+  private recordedAfterSeq = -1
+  private readonly lifetime = new AbortController()
+
+  constructor(
+    private readonly session: Session,
+    private readonly cwd: string,
+    private readonly env: RecorderEnvironment,
+  ) {}
+
+  /** Open a turn: reset per-turn state and queue the baseline snapshot. */
+  start(turn: number): void {
+    this.turn = turn
+    this.baseline = null
+    this.hunks = new Map()
+    this.lastToolResultSeq = -1
+    this.recordedAfterSeq = -1
+    void this.enqueue(async (signal) => {
+      const git = await this.env.git
+      if (git === null) return
+      // git reports symlink-resolved paths; every comparison uses that form.
+      const cwd = await realpath(this.cwd)
+      const workspace = await locateGitWorkspace(git, cwd, this.env.shadow, signal)
+      const tree = await snapshotTree(git, workspace, signal)
+      this.baseline = { git, workspace, tree, cwd }
+    })
+  }
+
+  /** Remember a settled tool result and any file-tool hunks it carries. */
+  observe(event: SessionEvent<'tool/result'>): void {
+    if (event.data.turn !== this.turn) return
+    this.lastToolResultSeq = event.seq
+    if (event.data.message.content[0].isError === true) return
+    const diffs = fileDiffsOf(event.data.meta)
+    if (diffs === undefined) return
+    for (const diff of diffs) {
+      const list = this.hunks.get(diff.path)
+      if (list === undefined) this.hunks.set(diff.path, [diff])
+      else list.push(diff)
+    }
+  }
+
+  /**
+   * Record the turn's changes inside the turn, before `turn/end` commits.
+   * @param turn - the stopping turn.
+   * @returns after the event is appended or the attempt failed.
+   */
+  stopping(turn: number): Promise<void> {
+    if (turn !== this.turn) return Promise.resolve()
+    return this.enqueue(signal => this.record(signal))
+  }
+
+  /** Record after `turn/end` when the in-turn record is missing or stale. */
+  end(turn: number): void {
+    if (turn !== this.turn || this.recordedAfterSeq >= this.lastToolResultSeq) return
+    void this.enqueue(signal => this.record(signal))
+  }
+
+  /** Resolves once every queued snapshot and record has settled. */
+  settled(): Promise<void> {
+    return this.chain
+  }
+
+  /** Abort queued git work; the recorder accepts nothing afterwards. */
+  dispose(): void {
+    this.lifetime.abort()
+  }
+
+  private enqueue(task: (signal: AbortSignal) => Promise<void>): Promise<void> {
+    const run = this.chain.then(async () => {
+      if (this.lifetime.signal.aborted) return
+      try {
+        await task(this.lifetime.signal)
+      } catch (error: unknown) {
+        this.warnUnlessDisposed(error)
+      }
+    })
+    this.chain = run
+    return run
+  }
+
+  /** A failure after disposal is expected cancellation and stays silent. */
+  private warnUnlessDisposed(error: unknown): void {
+    if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`)
+  }
+
+  private async record(signal: AbortSignal): Promise<void> {
+    if (this.baseline === null || this.lastToolResultSeq < 0) return
+    const { git, workspace, tree: before, cwd } = this.baseline
+    const after = await snapshotTree(git, workspace, signal)
+    const files = new Map<string, WorkspaceChangedFile>()
+    for (const entry of await diffTrees(git, workspace, before, after, signal)) {
+      const absolute = absolutePathOf(workspace.root, entry.path)
+      files.set(absolute, this.changedFile(absolute, cwd, workspace, entry))
+    }
+    const hunks = new Map<string, FileDiff[]>()
+    for (const [path, list] of this.hunks) {
+      const absolute = await canonicalPath(absolutePathOf(cwd, path))
+      hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list])
+    }
+    const inside: string[] = []
+    const outside: string[] = []
+    for (const absolute of hunks.keys()) {
+      if (files.has(absolute)) continue
+      // The working directory is the user's workspace even when it lives under a temporary root.
+      if (!isInside(cwd, absolute) && isTemporaryPath(absolute, this.env.temporaryRoots)) continue
+      if (isInside(workspace.root, absolute)) inside.push(absolute)
+      else outside.push(absolute)
+    }
+    const workTreePath = (absolute: string): string => toPosix(relative(workspace.root, absolute))
+    const ignored = await ignoredPaths(git, workspace, inside.map(workTreePath), signal)
+    const toolOnly = new Set([...outside, ...inside.filter(absolute => ignored.has(workTreePath(absolute)))])
+    for (const [absolute, list] of hunks) {
+      if (!toolOnly.has(absolute)) continue
+      files.set(absolute, this.changedFile(absolute, cwd, workspace, { ...hunkLineCounts(list), binary: false }))
+    }
+    const sorted = [...files.values()].sort(compareDisplay)
+    // An empty list after an earlier in-turn record supersedes that record.
+    if (sorted.length === 0 && this.recordedAfterSeq < 0) return
+    const event = this.session.append('workspace/changes', {
+      turn: this.turn,
+      files: sorted.slice(0, this.env.maxFiles),
+      total: sorted.length,
+      snapshot: { before, after },
+    })
+    this.recordedAfterSeq = event.seq
+  }
+
+  private changedFile(
+    absolute: string, cwd: string, workspace: GitWorkspace, counts: { added: number; deleted: number; binary: boolean },
+  ): WorkspaceChangedFile {
+    return {
+      path: durablePathOf(absolute, cwd),
+      display: displayPathOf(absolute, cwd, workspace.root, this.env.home),
+      added: counts.added,
+      deleted: counts.deleted,
+      ...counts.binary ? { binary: true as const } : {},
+    }
+  }
+}

+ 38 - 0
packages/fs/workspace-changes/src/types.ts

@@ -0,0 +1,38 @@
+/** Durable per-turn workspace change summaries and their Session event. */
+
+/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+export 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
+}
+
+/** Files changed during one top-level turn. */
+export interface WorkspaceChangesData {
+  /** The turn whose file changes this summary describes. */
+  turn: number
+  /** 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
+  /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */
+  snapshot: { before: string; after: string }
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */
+    'workspace/changes': WorkspaceChangesData
+  }
+}

+ 125 - 0
packages/fs/workspace-changes/tests/git.spec.ts

@@ -0,0 +1,125 @@
+/** Git command bounds, snapshot recovery, and diff failure reporting. */
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import { GitRunner, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree } from '../src/git.ts'
+import { TurnRecorder } from '../src/recorder.ts'
+import { temporaryRoots } from '../src/paths.ts'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import { git, scratchDir } from './support.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+})
+
+async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 }, executable = 'git') {
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(LocalSubprocessRuntime)
+  return { ctx, git: new GitRunner(ctx.subprocess, await ctx.subprocess.resolveExecutable(executable).catch(() => executable), limits) }
+}
+
+const signal = new AbortController().signal
+
+describe('GitRunner', () => {
+  it('reports timeouts and external aborts as failures', async () => {
+    const cwd = await scratchDir('dsh-git-runner-', cleanups)
+    const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 })
+    await expect(slow.run(['--version'], { cwd, signal })).rejects.toThrow('timed out after 1ms')
+    const { git: quick } = await runner()
+    const aborted = new AbortController()
+    setTimeout(() => { aborted.abort() }, 0)
+    await expect(quick.run(['--version'], { cwd, signal: aborted.signal })).rejects.toThrow('git --version was aborted')
+    const ok = await quick.run(['--version'], { cwd, signal })
+    expect(ok.exitCode).toBe(0)
+    expect(ok.stdout).toContain('git version')
+  })
+})
+
+describe('snapshots and diffs', () => {
+  it('snapshots a repository whose index holds unmerged entries without touching that index', async () => {
+    const cwd = await scratchDir('dsh-git-conflict-', cleanups)
+    git(cwd, 'init', '-q', '-b', 'main')
+    await writeFile(join(cwd, 'f.txt'), 'base\n')
+    git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'base')
+    git(cwd, 'checkout', '-q', '-b', 'side')
+    await writeFile(join(cwd, 'f.txt'), 'side\n')
+    git(cwd, 'commit', '-q', '-am', 'side')
+    git(cwd, 'checkout', '-q', 'main')
+    await writeFile(join(cwd, 'f.txt'), 'main\n')
+    git(cwd, 'commit', '-q', '-am', 'main')
+    expect(() => git(cwd, 'merge', 'side')).toThrow()
+    expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
+    const { git: runnerGit } = await runner()
+    const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
+    expect(workspace.kind).toBe('repository')
+    const tree = await snapshotTree(runnerGit, workspace, signal)
+    expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
+    expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt')
+  })
+
+  it('fails loudly when the addressed repository cannot be written or diffed', async () => {
+    const cwd = await scratchDir('dsh-git-broken-', cleanups)
+    const { git: runnerGit } = await runner()
+    const broken = { kind: 'shadow' as const, root: cwd, gitDir: join(cwd, 'missing'), env: { GIT_DIR: join(cwd, 'missing'), GIT_WORK_TREE: cwd } }
+    await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in')
+    await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed')
+    expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set())
+    git(cwd, 'init', '-q')
+    const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal)
+    await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed')
+    await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n'))
+    const before = await snapshotTree(runnerGit, workspace, signal)
+    await writeFile(join(cwd, 'many.txt'), 'gone')
+    for (let index = 0; index < 20; index += 1) await writeFile(join(cwd, `file-${index}.txt`), 'x\n')
+    const after = await snapshotTree(runnerGit, workspace, signal)
+    const { git: tiny } = await runner({ timeoutMs: 30_000, outputMaxBytes: 16 })
+    await expect(diffTrees(tiny, workspace, before, after, signal)).rejects.toThrow('exceeded')
+    expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21)
+  })
+
+  it('refuses a shadow home whose git directory cannot be initialized', async () => {
+    const cwd = await scratchDir('dsh-git-shadow-broken-', cleanups)
+    const home = join(cwd, 'home')
+    const { git: runnerGit } = await runner()
+    const probe = await locateGitWorkspace(runnerGit, cwd, { home, excludes: ['x/'] }, signal)
+    expect(probe.kind).toBe('shadow')
+    await mkdir(join(probe.gitDir, 'objects', 'broken'), { recursive: true })
+    await writeFile(join(probe.gitDir, 'HEAD'), 'not a ref')
+    await writeFile(join(probe.gitDir, 'config'), '[core]\n\tbare = maybe\n')
+    await expect(locateGitWorkspace(runnerGit, cwd, { home, excludes: [] }, signal)).rejects.toThrow('git init of shadow repository')
+  })
+})
+
+describe('TurnRecorder', () => {
+  it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => {
+    const cwd = await scratchDir('dsh-recorder-', cleanups)
+    const { ctx, git: runnerGit } = await runner()
+    const session = ctx.sessions.create(SessionId('recorder'), { meta: { cwd } })
+    const warnings: string[] = []
+    let release!: (runner: GitRunner | null) => void
+    const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
+    const env = { git: gate, shadow: { home: join(cwd, 'home'), excludes: [] }, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
+    const disposed = new TurnRecorder(session, cwd, env)
+    disposed.start(1)
+    await new Promise(resolve => setTimeout(resolve, 5))
+    disposed.dispose()
+    release(runnerGit)
+    await disposed.settled()
+    disposed.start(2)
+    await disposed.settled()
+    expect(warnings).toEqual([])
+
+    const { git: missing } = await runner(undefined, '/nonexistent/git-binary')
+    const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) })
+    failing.start(1)
+    await failing.settled()
+    expect(warnings).toHaveLength(1)
+    expect(warnings[0]).toContain('workspace-changes:')
+  })
+})

+ 82 - 0
packages/fs/workspace-changes/tests/loader-composition.spec.ts

@@ -0,0 +1,82 @@
+/**
+ * REAL-composition proof: the shipped YAML rows (session store, local
+ * subprocess runtime, workspace-changes) boot through the vendored Loader and
+ * a logged turn ends with its recorded changes.
+ */
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import * as WorkspaceChangesPlugin from '@deepseek-ai/dsh-workspace-changes'
+import { changes, endTurn, git, startTurn, toolCall } from './support.ts'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+  await context?.fiber.dispose()
+  context = undefined
+  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  root = undefined
+})
+
+describe('real Loader composition', () => {
+  it('loads the shipped rows and records a turn’s changes', async () => {
+    root = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-loader-'))
+    const cwd = join(root, 'ws')
+    await writeFile(join(root, 'cordis.yml'), [
+      "- name: '@deepseek-ai/dsh-session'",
+      "- name: '@deepseek-ai/dsh-subprocess-local'",
+      "- name: '@deepseek-ai/dsh-workspace-changes'",
+      '  config:',
+      `    dshHome: ${JSON.stringify(join(root, 'home'))}`,
+      '',
+    ].join('\n'))
+    context = new Context()
+    context.baseUrl = `${pathToFileURL(root).href}/`
+    await context.plugin(Loader)
+    context.loader.builtins.include = Include
+    const modules = new Map<string, unknown>([
+      ['@deepseek-ai/dsh-session', SessionStore],
+      ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
+      ['@deepseek-ai/dsh-workspace-changes', WorkspaceChangesPlugin],
+    ])
+    context.loader.internal = {
+      version: 'v2',
+      async import(specifier: string) {
+        if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+        return modules.get(specifier)
+      },
+    } as unknown as NonNullable<typeof context.loader.internal>
+    await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(root, 'cordis.yml')).href } })
+    await context.loader.await()
+    const unloaded = [...context.loader.entries()]
+      .filter(entry => entry.fiber === undefined && !entry.disabled)
+      .map(entry => entry.options.name)
+    expect(unloaded).toEqual([])
+
+    await writeFile(join(root, 'placeholder'), '')
+    await rm(cwd, { recursive: true, force: true })
+    git(root, 'init', '-q', cwd)
+    await writeFile(join(cwd, 'tracked.txt'), 'one\n')
+    git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'init')
+    const session = context.sessions.create(SessionId('composed'), { meta: { cwd } })
+    startTurn(session, 1)
+    await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never))
+    await writeFile(join(cwd, 'tracked.txt'), 'one\ntwo\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    endTurn(session, 1)
+    await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never))
+    const [recorded, ...rest] = changes(session)
+    expect(rest).toEqual([])
+    expect(recorded).toMatchObject({ turn: 1, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] })
+    expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]+$/)
+    expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]+$/)
+  })
+})

+ 46 - 0
packages/fs/workspace-changes/tests/numstat.spec.ts

@@ -0,0 +1,46 @@
+/** Numstat parsing and hunk line counting. */
+import { describe, expect, it } from 'vitest'
+import { fileDiffsOf, hunkLineCounts, parseNumstat } from '../src/numstat.ts'
+
+describe('parseNumstat', () => {
+  it('reads plain, binary, and rename records', () => {
+    const output = ['3\t1\tsrc/a.ts', '-\t-\timg.png', '2\t0\t', 'old.txt', 'new.txt', ''].join('\u0000')
+    expect(parseNumstat(output)).toEqual([
+      { path: 'src/a.ts', added: 3, deleted: 1, binary: false },
+      { path: 'img.png', added: 0, deleted: 0, binary: true },
+      { path: 'new.txt', added: 2, deleted: 0, binary: false },
+    ])
+  })
+
+  it('returns nothing for empty output', () => {
+    expect(parseNumstat('')).toEqual([])
+  })
+
+  it('rejects output without a final terminator or with malformed records', () => {
+    expect(() => parseNumstat('1\t1\ta.txt')).toThrow('NUL-terminated')
+    expect(() => parseNumstat('garbage\0')).toThrow('malformed numstat record')
+    expect(() => parseNumstat('1\t0\t\0old.txt\0')).toThrow('rename')
+  })
+})
+
+describe('hunkLineCounts', () => {
+  it('counts changed lines and ignores context', () => {
+    expect(hunkLineCounts([
+      { path: 'a', oldText: 'keep\nold\nkeep', newText: 'keep\nnew\nnew2\nkeep' },
+      { path: 'a', oldText: null, newText: 'x\ny\n' },
+    ])).toEqual({ added: 4, deleted: 1 })
+  })
+})
+
+describe('fileDiffsOf', () => {
+  it('narrows the file-tool metadata and rejects anything else', () => {
+    expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: null, newText: 'b' }] })).toEqual([{ path: 'a', oldText: null, newText: 'b' }])
+    expect(fileDiffsOf(undefined)).toBeUndefined()
+    expect(fileDiffsOf([])).toBeUndefined()
+    expect(fileDiffsOf({ diffs: [] })).toBeUndefined()
+    expect(fileDiffsOf({ diffs: [null] })).toBeUndefined()
+    expect(fileDiffsOf({ diffs: [{ path: 1, oldText: null, newText: '' }] })).toBeUndefined()
+    expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 2, newText: '' }] })).toBeUndefined()
+    expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 'x', newText: 3 }] })).toBeUndefined()
+  })
+})

+ 46 - 0
packages/fs/workspace-changes/tests/paths.spec.ts

@@ -0,0 +1,46 @@
+/** Display, durable, and temporary path rules. */
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { compareDisplay, displayPathOf, durablePathOf, isTemporaryPath, temporaryRoots } from '../src/paths.ts'
+
+const cwd = '/home/u/proj/pkg'
+const root = '/home/u/proj'
+const home = '/home/u'
+
+describe('displayPathOf', () => {
+  it('prefers cwd-relative, then repository-relative, then home, then absolute', () => {
+    expect(displayPathOf('/home/u/proj/pkg/src/a.ts', cwd, root, home)).toBe('src/a.ts')
+    expect(displayPathOf('/home/u/proj/other/b.ts', cwd, root, home)).toBe('../other/b.ts')
+    expect(displayPathOf('/home/u/.zshrc', cwd, root, home)).toBe('~/.zshrc')
+    expect(displayPathOf('/etc/hosts', cwd, root, home)).toBe('/etc/hosts')
+    expect(displayPathOf('/home/u/.zshrc', cwd, root, '')).toBe('/home/u/.zshrc')
+  })
+})
+
+describe('durablePathOf', () => {
+  it('keeps cwd-relative paths relative and everything else absolute', () => {
+    expect(durablePathOf('/home/u/proj/pkg/a.ts', cwd)).toBe('a.ts')
+    expect(durablePathOf('/home/u/proj/b.ts', cwd)).toBe('/home/u/proj/b.ts')
+  })
+})
+
+describe('temporary paths', () => {
+  it('matches the platform temp roots in raw and canonical form and skips missing candidates', () => {
+    const roots = temporaryRoots()
+    expect(roots).toContain('/tmp')
+    expect(isTemporaryPath(join(tmpdir(), 'scratch.txt'), roots)).toBe(true)
+    expect(isTemporaryPath('/tmp/x', roots)).toBe(true)
+    expect(isTemporaryPath('/tmpfoo/x', roots)).toBe(false)
+    expect(isTemporaryPath('/home/u/x', roots)).toBe(false)
+    expect(temporaryRoots(['/definitely/missing/root'])).toEqual(['/definitely/missing/root'])
+  })
+})
+
+describe('compareDisplay', () => {
+  it('orders by code units so parent and absolute paths lead', () => {
+    const sorted = [{ display: 'src/b' }, { display: '~/x' }, { display: '../a' }, { display: '/etc/h' }, { display: 'src/a' }].sort(compareDisplay)
+    expect(sorted.map(file => file.display)).toEqual(['../a', '/etc/h', 'src/a', 'src/b', '~/x'])
+    expect(compareDisplay({ display: 'a' }, { display: 'a' })).toBe(0)
+  })
+})

+ 334 - 0
packages/fs/workspace-changes/tests/plugin.spec.ts

@@ -0,0 +1,334 @@
+/** The plugin records each top-level turn's file changes from real git snapshots. */
+import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'
+import { homedir, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-agent'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import * as WorkspaceChanges from '../src/index.ts'
+import { changes, endTurn, git, scratchDir, settle, startTurn, toolCall } from './support.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+
+async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(LocalSubprocessRuntime)
+  const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups)
+  const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config)
+  return { ctx, fiber, dshHome }
+}
+
+async function repository(): Promise<string> {
+  const cwd = await scratchDir('dsh-workspace-changes-repo-', cleanups)
+  git(cwd, 'init', '-q', '-b', 'main')
+  await writeFile(join(cwd, 'a.txt'), 'l1\nl2\nl3\n')
+  await writeFile(join(cwd, 'b.txt'), 'x\n')
+  await writeFile(join(cwd, 'same.txt'), 'same\n')
+  await writeFile(join(cwd, '.gitignore'), '.env*\n')
+  git(cwd, 'add', '-A')
+  git(cwd, 'commit', '-q', '-m', 'init')
+  return cwd
+}
+
+describe('workspace-changes in a repository', () => {
+  it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => {
+    const cwd = await repository()
+    await writeFile(join(cwd, 'b.txt'), 'x user\n')
+    await writeFile(join(cwd, 'u.txt'), 'user untracked\n')
+    const { ctx } = await boot()
+    const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+
+    await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n')
+    toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
+    await mkdir(join(cwd, 'sub', 'dir'), { recursive: true })
+    await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n')
+    await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n')
+    await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
+    toolCall(session, 1, 'bash', { command: 'printf > files' })
+    await writeFile(join(cwd, '.env'), 'A=1\nB=2\n')
+    toolCall(session, 1, 'write', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: null, newText: 'A=1\n' }] } })
+    toolCall(session, 1, 'edit', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] } })
+    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt') }, {
+      meta: { diffs: [{ path: join(tmpdir(), 'scratch.txt'), oldText: null, newText: 'scratch\n' }] },
+    })
+    toolCall(session, 1, 'write', { file_path: 'ignored-error' }, { isError: true, meta: { diffs: [{ path: 'failed.txt', oldText: null, newText: 'x' }] } })
+    toolCall(session, 1, 'write', { file_path: '.env.gone' }, { meta: { diffs: [{ path: '.env.gone', oldText: null, newText: 'x' }] } })
+    toolCall(session, 1, 'write', { file_path: 'same.txt' }, { meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] } })
+    toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
+    endTurn(session, 1)
+    await settle(ctx, session)
+
+    const [recorded, ...rest] = changes(session)
+    expect(rest).toEqual([])
+    expect(recorded).toMatchObject({ turn: 1, total: 6 })
+    expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]{40,64}$/)
+    expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]{40,64}$/)
+    expect(recorded!.files).toEqual([
+      { path: '.env', display: '.env', added: 2, deleted: 0 },
+      { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 },
+      { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 },
+      { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
+      { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 },
+      { path: 'sub/dir/c.txt', display: 'sub/dir/c.txt', added: 1, deleted: 0 },
+    ])
+    expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([
+      ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt',
+    ])
+  })
+
+  it('places files above the working directory and outside the repository by their display rule', async () => {
+    const root = await repository()
+    const cwd = join(root, 'pkg')
+    await mkdir(cwd)
+    const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-test-'))
+    cleanups.push(() => rm(outside, { recursive: true, force: true }))
+    const { ctx } = await boot()
+    const session = ctx.sessions.create(SessionId('nested'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await writeFile(join(root, 'a.txt'), 'changed\n')
+    await writeFile(join(cwd, 'inner.txt'), 'inner\n')
+    toolCall(session, 1, 'write', { file_path: join(outside, 'note.txt') }, {
+      meta: { diffs: [{ path: join(outside, 'note.txt'), oldText: null, newText: 'one\ntwo\nthree\n' }] },
+    })
+    endTurn(session, 1, 'blocked')
+    await settle(ctx, session)
+    const [recorded] = changes(session)
+    expect(recorded!.files).toEqual([
+      { path: join(await realpath(root), 'a.txt'), display: '../a.txt', added: 1, deleted: 3 },
+      { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 },
+      { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 },
+    ])
+  })
+
+  it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => {
+    const cwd = await repository()
+    const { ctx } = await boot()
+    const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } })
+    const agent = { session } as never
+    const signal = new AbortController().signal
+    startTurn(session, 1)
+    await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
+    expect(changes(session)).toEqual([])
+    await writeFile(join(cwd, 'one.txt'), '1\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    await ctx.serial('agent/turn-stopping', { agent, turn: 7, signal })
+    await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
+    const inTurn = session.snapshotEvents().find(event => event.type === 'workspace/changes')
+    expect(inTurn?.data).toMatchObject({ turn: 1, total: 1 })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    expect(changes(session)).toHaveLength(1)
+    expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq)
+
+    startTurn(session, 2)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'two.txt'), '2\n')
+    toolCall(session, 2, 'bash', { command: 'x' })
+    await ctx.serial('agent/turn-stopping', { agent, turn: 2, signal })
+    await rm(join(cwd, 'two.txt'))
+    toolCall(session, 2, 'bash', { command: 'steered' })
+    endTurn(session, 2)
+    await settle(ctx, session)
+    const second = changes(session).filter(data => data.turn === 2)
+    expect(second.map(data => data.files.map(file => file.path))).toEqual([['two.txt'], []])
+    expect(second[1]).toMatchObject({ total: 0 })
+
+    startTurn(session, 3)
+    await settle(ctx, session)
+    toolCall(session, 3, 'read', { file_path: 'a.txt' })
+    endTurn(session, 3)
+    await settle(ctx, session)
+    expect(changes(session).filter(data => data.turn === 3)).toEqual([])
+  })
+
+  it('caps the file list while reporting the complete count', async () => {
+    const cwd = await repository()
+    const { ctx } = await boot({ maxFiles: 2 })
+    const session = ctx.sessions.create(SessionId('capped'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`)
+    toolCall(session, 1, 'bash', { command: 'x' })
+    toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    const [recorded] = changes(session)
+    expect(recorded!.total).toBe(3)
+    expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
+  })
+
+  it('warns and skips the turn when its git work fails', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-warn-', cleanups)
+    const blocker = join(cwd, 'blocker')
+    await writeFile(blocker, 'not a directory')
+    const { ctx } = await boot({ dshHome: blocker })
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+    const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'x.txt'), 'x\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    expect(changes(session)).toEqual([])
+    const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:'))
+    expect(own).toHaveLength(1)
+    expect(own[0]).toContain('ENOTDIR')
+  })
+
+  it('rejects non-positive bounds at load', async () => {
+    const ctx = new Context()
+    cleanups.push(() => ctx.fiber.dispose())
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(LocalSubprocessRuntime)
+    await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles')
+  })
+})
+
+describe('workspace-changes without a repository', () => {
+  it('snapshots into a shadow repository under the Harness home and excludes that home', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups)
+    const dshHome = join(cwd, '.dsh')
+    await writeFile(join(cwd, 'existing.txt'), 'before\n')
+    const { ctx } = await boot({ dshHome })
+    const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
+    await mkdir(join(cwd, 'node_modules'), { recursive: true })
+    await writeFile(join(cwd, 'node_modules', 'dep.js'), 'module\n')
+    await writeFile(join(dshHome, 'settings.yaml'), 'changed: true\n')
+    toolCall(session, 1, 'write', { file_path: 'node_modules/dep.js' }, {
+      meta: { diffs: [{ path: 'node_modules/dep.js', oldText: null, newText: 'module\n' }] },
+    })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    const [first] = changes(session)
+    expect(first!.files).toEqual([
+      { path: 'existing.txt', display: 'existing.txt', added: 2, deleted: 1 },
+      { path: 'node_modules/dep.js', display: 'node_modules/dep.js', added: 1, deleted: 0 },
+    ])
+    const shadows = join(dshHome, 'workspace-changes')
+    expect((await stat(shadows)).isDirectory()).toBe(true)
+    await expect(stat(join(cwd, '.git'))).rejects.toThrow()
+
+    startTurn(session, 2)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'second.txt'), 's\n')
+    toolCall(session, 2, 'bash', { command: 'x' })
+    endTurn(session, 2)
+    await settle(ctx, session)
+    expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['second.txt'])
+  })
+
+  it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-disposed-', cleanups)
+    const { ctx, fiber } = await boot()
+    const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    ctx.emit('session/disposed', session)
+    await writeFile(join(cwd, 'one.txt'), '1\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    expect(changes(session)).toEqual([])
+    startTurn(session, 2)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'two.txt'), '2\n')
+    toolCall(session, 2, 'bash', { command: 'x' })
+    endTurn(session, 2)
+    await settle(ctx, session)
+    expect(changes(session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']])
+
+    startTurn(session, 3)
+    await settle(ctx, session)
+    await fiber.dispose()
+    await writeFile(join(cwd, 'three.txt'), '3\n')
+    toolCall(session, 3, 'bash', { command: 'x' })
+    endTurn(session, 3)
+    await settle(ctx, session)
+    expect(changes(session).filter(data => data.turn === 3)).toEqual([])
+  })
+
+  it('ignores subagent sessions and sessions without a working directory', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-skip-', cleanups)
+    const { ctx } = await boot()
+    const sessions = [
+      ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }),
+      ctx.sessions.create(SessionId('origin'), { meta: { cwd, origin: 'subagent' } }),
+      ctx.sessions.create(SessionId('nowhere')),
+    ]
+    for (const session of sessions) {
+      startTurn(session, 1)
+      await settle(ctx, session)
+      toolCall(session, 1, 'bash', { command: 'x' })
+      endTurn(session, 1)
+      await settle(ctx, session)
+      expect(changes(session)).toEqual([])
+    }
+    await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
+  })
+})
+
+describe('workspace-changes without git', () => {
+  it('records nothing and reports the absence once', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-nogit-', cleanups)
+    const { ctx } = await boot()
+    vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found'))
+    const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
+    const session = ctx.sessions.create(SessionId('nogit'), { meta: { cwd } })
+    for (const turn of [1, 2]) {
+      startTurn(session, turn)
+      await settle(ctx, session)
+      await writeFile(join(cwd, `${turn}.txt`), 'x\n')
+      toolCall(session, turn, 'bash', { command: 'x' })
+      endTurn(session, turn)
+      await settle(ctx, session)
+    }
+    expect(changes(session)).toEqual([])
+    expect(info).toHaveBeenCalledTimes(1)
+    expect(info.mock.calls[0]![0]).toContain('git is unavailable')
+  })
+
+  it('treats the macOS developer-tools stub as absent until a developer directory is selected', async () => {
+    const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
+    Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
+    cleanups.push(async () => { Object.defineProperty(process, 'platform', platform) })
+    const probes: Array<{ done: Promise<{ exitCode: number | null; signal: null }>; available: boolean; executable?: string }> = [
+      { done: Promise.reject(new Error('spawn failed')), available: false },
+      { done: Promise.resolve({ exitCode: 1, signal: null }), available: false },
+      { done: Promise.resolve({ exitCode: 0, signal: null }), available: true },
+      { done: Promise.resolve({ exitCode: 1, signal: null }), available: true, executable: '/opt/homebrew/bin/git' },
+    ]
+    for (const probe of probes) {
+      probe.done.catch(() => undefined)
+      const cwd = await scratchDir('dsh-workspace-changes-stub-', cleanups)
+      const { ctx } = await boot()
+      vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(probe.executable ?? '/usr/bin/git')
+      const real = ctx.subprocess.spawn.bind(ctx.subprocess)
+      const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(spec =>
+        spec.argv[0] === '/usr/bin/xcode-select' ? { done: probe.done } as never : real(spec))
+      const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined)
+      vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+      const session = ctx.sessions.create(SessionId('stub'), { meta: { cwd } })
+      startTurn(session, 1)
+      await settle(ctx, session)
+      expect(spawn.mock.calls.some(call => call[0].argv[0] === '/usr/bin/xcode-select')).toBe(probe.executable === undefined)
+      expect(info).toHaveBeenCalledTimes(probe.available ? 0 : 1)
+      await ctx.fiber.dispose()
+    }
+  })
+})

+ 67 - 0
packages/fs/workspace-changes/tests/support.ts

@@ -0,0 +1,67 @@
+/** Shared fixtures: temporary git repositories, logged turns, and event readers. */
+import { execFileSync } from 'node:child_process'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import { ToolCallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-tools'
+import type { WorkspaceChangesData } from '../src/types.ts'
+
+let callNumber = 0
+
+/** Run git synchronously inside a fixture repository. */
+export function git(cwd: string, ...args: string[]): string {
+  return execFileSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], {
+    cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
+  })
+}
+
+/** A temporary directory removed by the returned cleanup. */
+export async function scratchDir(prefix: string, cleanups: Array<() => Promise<unknown>>): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), prefix))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+/** Open a turn with its first step. */
+export function startTurn(session: Session, turn: number): void {
+  session.append('turn/start', { turn })
+  session.append('step/start', { turn, step: 1 })
+}
+
+/** Log one settled tool call with an optional result `meta`. */
+export function toolCall(session: Session, turn: number, name: string, args: unknown, result: { meta?: unknown; isError?: boolean } = {}) {
+  const callId = ToolCallId(`call-${++callNumber}`)
+  const serialized = JSON.stringify(args)
+  session.append('assistant/message', {
+    stream: [], turn, step: 1,
+    message: createAssistantMessage({
+      content: [{ type: 'tool-call', id: callId, name, arguments: serialized }],
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+  }, { surfaceOp: 'append' })
+  const source = session.append('tool/call', { turn, step: 1, callId, name, arguments: serialized })
+  return session.append('tool/result', {
+    turn, step: 1,
+    message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'ok' }], isError: result.isError ?? false }),
+    ...result.meta === undefined ? {} : { meta: result.meta as never },
+  }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
+}
+
+/** Close the step and the turn. */
+export function endTurn(session: Session, turn: number, reason: 'completed' | 'blocked' = 'completed'): void {
+  session.append('step/end', { turn, step: 1 })
+  session.append('turn/end', { turn, reason: { kind: reason } })
+}
+
+/** Wait for the plugin's queued git work through the same waterfall tool execution uses. */
+export async function settle(ctx: Context, session: Session): Promise<void> {
+  await ctx.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never))
+}
+
+/** Recorded change events of one session. */
+export function changes(session: Session): WorkspaceChangesData[] {
+  return session.snapshotEvents().filter(event => event.type === 'workspace/changes').map(event => event.data)
+}

+ 33 - 0
packages/fs/workspace-changes/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../util/home-paths"
+    }
+  ]
+}

+ 49 - 0
pnpm-lock.yaml

@@ -616,6 +616,9 @@ importers:
       '@deepseek-ai/dsh-pwsh-local':
         specifier: workspace:^
         version: link:../../packages/shell/pwsh-local
+      '@deepseek-ai/dsh-workspace-changes':
+        specifier: workspace:^
+        version: link:../../packages/fs/workspace-changes
       '@types/node':
         specifier: ^22.0.0
         version: 22.20.0
@@ -1985,6 +1988,9 @@ importers:
       '@deepseek-ai/dsh-workspace':
         specifier: workspace:^
         version: link:../../workspace/workspace
+      '@deepseek-ai/dsh-workspace-changes':
+        specifier: workspace:^
+        version: link:../../fs/workspace-changes
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -2770,6 +2776,9 @@ importers:
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path
+      '@deepseek-ai/dsh-workspace-changes':
+        specifier: workspace:^
+        version: link:../../fs/workspace-changes
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -6258,6 +6267,46 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
 
+  packages/fs/workspace-changes:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      diff:
+        specifier: ^9.0.0
+        version: 9.0.0
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-home-paths':
+        specifier: workspace:^
+        version: link:../../util/home-paths
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-subprocess':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess-local
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+
   packages/goal/command-goal:
     devDependencies:
       '@deepseek-ai/cordis':

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
   'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
   'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
+  'packages/fs/workspace-changes': { kind: 'none', reason: 'The recorder appends a log-only Session event that only clients read; it registers nothing model-facing.' },
   'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' },
   'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' },
   'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },

+ 2 - 1
tsconfig.base.json

@@ -457,7 +457,8 @@
       "@deepseek-ai/dsh-workflow/invariant": ["./packages/workflow/workflow/src/invariant.ts"],
       "@deepseek-ai/dsh-workflow-worker-thread": ["./packages/workflow/workflow-worker-thread/src"],
       "@deepseek-ai/dsh-workspace": ["./packages/workspace/workspace/src"],
-      "@deepseek-ai/dsh-workspace/invariant": ["./packages/workspace/workspace/src/invariant.ts"]
+      "@deepseek-ai/dsh-workspace/invariant": ["./packages/workspace/workspace/src/invariant.ts"],
+      "@deepseek-ai/dsh-workspace-changes": ["./packages/fs/workspace-changes/src"]
       // END generated package aliases
     }
   }

+ 2 - 1
tsconfig.host.json

@@ -83,7 +83,7 @@
     "apps/web/tests/feedback-release.e2e.ts",
     "apps/web/tests/goal-command-presentation.e2e.ts",
     "apps/web/tests/startup-auto-selection.e2e.ts",
-    "apps/web/tests/produced-files.e2e.ts",
+    "apps/web/tests/changed-files.e2e.ts",
     "apps/web/tests/produced-file-mentions.e2e.ts",
     "apps/web/tests/subagent-conversation.e2e.ts",
     "apps/web/tests/subagent-interrupt.e2e.ts",
@@ -273,6 +273,7 @@
     { "path": "./packages/fs/fs-sandbox" },
     { "path": "./packages/fs/tool-fs" },
     { "path": "./packages/fs/tool-present" },
+    { "path": "./packages/fs/workspace-changes" },
     { "path": "./packages/fs/tool-fs-search" },
     { "path": "./packages/fs/tool-str-replace-editor" },
     { "path": "./packages/compaction/compaction" },