Преглед изворни кода

feat(workspace-changes): keep change summaries on the Host for the Session's lifetime

The workspace/changes event now carries only the turn number. The
recorder keeps each announced summary in memory under the event's
sequence and serves it through the new workspaceChanges service; the
Web deliverables plugin reads it once per announcement through an
authenticated summary route and renders the card only while the Host
still serves it, so a conversation reopened after a Host restart has no
card for its earlier turns. Snapshot objects move from a shared,
size-bounded store under the Harness home to a temporary directory per
Session that disposal removes, which retires the dshHome and
objectStoreMaxBytes fields. The cold-seeded fold scenario goes with the
logged file list; the client spec covers the fold and the recorded
scenario covers the served summary.
creatixchu пре 4 дана
родитељ
комит
dde4dccbd2
71 измењених фајлова са 861 додато и 1238 уклоњено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 12 6
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 12 6
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  4. 12 9
      apps/web/tests/changed-files-turn.e2e.ts
  5. 0 148
      apps/web/tests/changed-files.e2e.ts
  6. 0 9
      apps/web/tests/changed-files.overlay.yml
  7. 0 2
      apps/web/tests/scaffold.ts
  8. 0 1
      apps/web/tsconfig.json
  9. 2 2
      docs/capability-seams.i18n.yaml
  10. 4 0
      docs/capability-seams.md
  11. 4 0
      docs/capability-seams.zh.md
  12. 2 2
      docs/config-catalog.i18n.yaml
  13. 4 8
      docs/config-catalog.md
  14. 3 7
      docs/config-catalog.zh.md
  15. 2 2
      docs/module-graph.i18n.yaml
  16. 1 2
      docs/module-graph.md
  17. 1 2
      docs/module-graph.zh.md
  18. 2 2
      docs/persistence-catalog.i18n.yaml
  19. 11 61
      docs/persistence-catalog.md
  20. 11 61
      docs/persistence-catalog.zh.md
  21. 2 2
      docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml
  22. 1 1
      docs/persistence-changes/2026-09-14-workspace-changes-event.md
  23. 6 76
      docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json
  24. 1 1
      docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md
  25. 2 2
      docs/persistence-changes/historical-formats/README.i18n.yaml
  26. 1 1
      docs/persistence-changes/historical-formats/README.md
  27. 1 1
      docs/persistence-changes/historical-formats/README.zh.md
  28. 76 467
      docs/persistence-schema.json
  29. 2 2
      docs/subsystems/README.i18n.yaml
  30. 1 1
      docs/subsystems/README.md
  31. 1 1
      docs/subsystems/README.zh.md
  32. 2 2
      docs/subsystems/deliverables.i18n.yaml
  33. 52 8
      docs/subsystems/deliverables.md
  34. 52 8
      docs/subsystems/deliverables.zh.md
  35. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  36. 4 4
      packages/client/ui-deliverables/README.md
  37. 4 4
      packages/client/ui-deliverables/README.zh.md
  38. 43 14
      packages/client/ui-deliverables/src/changes.ts
  39. 3 3
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  40. 25 9
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  41. 60 0
      packages/client/ui-deliverables/src/client/changes-summary.ts
  42. 9 3
      packages/client/ui-deliverables/src/client/index.ts
  43. 7 14
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  44. 3 2
      packages/client/ui-deliverables/src/index.ts
  45. 27 12
      packages/client/ui-deliverables/src/present-open.ts
  46. 47 31
      packages/client/ui-deliverables/tests/changes-open.host.spec.ts
  47. 91 21
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  48. 1 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  49. 2 2
      packages/deliverables/README.i18n.yaml
  50. 1 1
      packages/deliverables/README.md
  51. 1 1
      packages/deliverables/README.zh.md
  52. 2 2
      packages/deliverables/workspace-changes/README.i18n.yaml
  53. 10 13
      packages/deliverables/workspace-changes/README.md
  54. 10 13
      packages/deliverables/workspace-changes/README.zh.md
  55. 0 2
      packages/deliverables/workspace-changes/package.json
  56. 12 35
      packages/deliverables/workspace-changes/src/git.ts
  57. 29 29
      packages/deliverables/workspace-changes/src/index.ts
  58. 40 20
      packages/deliverables/workspace-changes/src/recorder.ts
  59. 30 7
      packages/deliverables/workspace-changes/src/types.ts
  60. 36 14
      packages/deliverables/workspace-changes/tests/git.spec.ts
  61. 2 6
      packages/deliverables/workspace-changes/tests/loader-composition.spec.ts
  62. 31 59
      packages/deliverables/workspace-changes/tests/plugin.spec.ts
  63. 7 4
      packages/deliverables/workspace-changes/tests/support.ts
  64. 21 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  65. 0 3
      pnpm-lock.yaml
  66. 2 0
      scripts/gen-cordis-catalog.ts
  67. 7 0
      scripts/gen-doc-graphs.ts
  68. 1 1
      scripts/gen-persistence-catalog.ts
  69. 6 1
      scripts/type-equiv.manifest.json
  70. 0 0
      snapshots/web/changed-files-turn/session.v3.jsonl
  71. 0 1
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-11-turn-changed-files-card.md
-2026-09-11-turn-changed-files-card.md: aca452b356715fc2c5e1e1b7360c71d020c221b2
-2026-09-11-turn-changed-files-card.zh.md: 721819a608a75aff1e9aac03f20138f6dddaf055
+2026-09-11-turn-changed-files-card.md: 9e5ca9689e2af3a650f596bdd8da980396ddb4d4
+2026-09-11-turn-changed-files-card.zh.md: 4af94d62eb434ee244a80f63290c256d0960c826

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

@@ -10,17 +10,19 @@ After a turn, users want to see which files the model changed and by how much. T
 
 ## Decision
 
-The Host [workspace-changes](../../../../packages/deliverables/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 Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) plugin summarizes each top-level turn's changed files, announces the summary with a `workspace/changes` Session event that carries only the turn number, and serves the summary through its `workspaceChanges` service until the Session is disposed; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) reads the served summary and renders it as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible.
 
-The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a private object store under the Harness home while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention; this placement follows the workspace change journal POC (#2973), which keeps every captured byte outside the workspace, and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
+The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
 
 Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into.
 
 Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
 
-The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The event carries both snapshot tree ids for a later full-file comparison.
+The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar.
 
-The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered and the card renders from the log alone. The Client keeps the latest event per turn.
+The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route.
+
+The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and the tree ids a later content comparison needs live only as long as the Session in the Host process: the summaries in the recorder, the objects in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Whether to keep content across restarts, and for how many turns, is left to #3822.
 
 ## Alternatives considered
 
@@ -32,7 +34,11 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t
 
 **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.
+**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose.
+
+**Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime.
+
+**A snapshot object store under the Harness home with a byte bound** was the first implementation's placement: one store per repository, shared by every Session, discarded when it outgrew the bound. It survived Host restarts that the summaries no longer do, needed two configuration fields, and made a Session's snapshot fail when another Session discarded the store; a per-Session temporary directory removes all three.
 
 **An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place.
 
@@ -40,7 +46,7 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t
 
 ## 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 private store; a store over its configured bound is discarded, 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.
+Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff.
 
 The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
 

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

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

+ 12 - 9
apps/web/tests/changed-files-turn.e2e.ts

@@ -90,16 +90,19 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     cwd = session.header.cwd
     if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
 
-    const summaries = session.snapshotEvents().filter(event => event.type === 'workspace/changes')
-    const summary = summaries.at(-1)
-    expect(summary, 'the turn must record its changed files').toBeDefined()
-    if (summary === undefined) throw new Error('no changed-files summary')
+    const announced = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
+    expect(announced, 'the turn must announce its changed files').toBeDefined()
+    if (announced === undefined) throw new Error('no changed-files announcement')
+    expect(announced.data).toEqual({ turn: 1 })
+    // The log carries only the turn; the Host serves the summary for the announcing event while the Session lives.
+    const summary = scaffold.ctx.workspaceChanges.summary(sessionId, announced.seq)
+    if (summary === undefined) throw new Error('the Host serves no summary for the announcement')
     // app.local is ignored by the repository, so its counts come from the write call rather than git.
-    expect(summary.data.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
-    expect(summary.data.total).toBe(4)
-    for (const file of summary.data.files) expect(file.added).toBeGreaterThan(0)
-    expect(summary.data.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
-    expect(summary.data.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
+    expect(summary.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
+    expect(summary.total).toBe(4)
+    for (const file of summary.files) expect(file.added).toBeGreaterThan(0)
+    expect(summary.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
+    expect(summary.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
     expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
 
     const card = page.locator('[data-changed-files]')

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

@@ -1,148 +0,0 @@
-// 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)
-})

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

@@ -1,9 +0,0 @@
-# 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
-  config:
-    nativeOpen: true
-- id: settings-controller
-  config:
-    nativeOpen: true

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

@@ -609,8 +609,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       : [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
     { id: 'settings', config: { dshHome: harnessHome } },
     { id: 'credentials', config: { dshHome: harnessHome } },
-    // Snapshot object stores of git-seeded scenarios stay inside the owned harness home.
-    { id: 'workspace-changes', config: { dshHome: harnessHome } },
     // 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

+ 0 - 1
apps/web/tsconfig.json

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

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 839d241869f8f88125cd7db6ad75646f25b4460d
-capability-seams.zh.md: eb6088bf63e51e6a4bb1c41453dd8a2af102b610
+capability-seams.md: 2bea18dad43fb8d71504b7192170d3a0eee2cb7b
+capability-seams.zh.md: 4184a8ca2ff8d201f3a470f8125ec598d25540f0

+ 4 - 0
docs/capability-seams.md

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

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

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

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 6b5a03e9f5f59ff987093a94334b00b0d81b2c9d
-config-catalog.zh.md: 6eb6c566b87ad54a886b272b18f13473b8b2ec63
+config-catalog.md: 43e2e391473d98d0bd1a5902e30b44d306b86c9a
+config-catalog.zh.md: f2b1230d7ac4d269754419a2cde3f1274c073d93

+ 4 - 8
docs/config-catalog.md

@@ -3590,22 +3590,18 @@ Source: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/
 Requires: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds and object store placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
-  /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */
-  objectStoreMaxBytes: number
   /** 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. */
+  /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
 }
 ```
 
-Source: [`packages/deliverables/workspace-changes/src/index.ts:28`](../packages/deliverables/workspace-changes/src/index.ts)
+Source: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
 
 ## Loadable plugins with no config
 
@@ -3629,7 +3625,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 3 - 7
docs/config-catalog.zh.md

@@ -3592,22 +3592,18 @@ export interface Config {
 依赖: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds and object store placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
-  /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */
-  objectStoreMaxBytes: number
   /** 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. */
+  /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
 }
 ```
 
-来源: [`packages/deliverables/workspace-changes/src/index.ts:28`](../packages/deliverables/workspace-changes/src/index.ts)
+来源: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
 
 ## 无配置的可加载插件
 

+ 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: e5c37f2ac69c89f64b796d1754cc00643075860b
-module-graph.zh.md: 84236800283a184c12fe9cb30feef2934d07f030
+module-graph.md: 677e090d145577acf1e085c2bad951988882e366
+module-graph.zh.md: 803296c238570d96f907c0f99a8d521b320ea05a

+ 1 - 2
docs/module-graph.md

@@ -818,7 +818,6 @@ flowchart TD
   pkg_tool_present --> pkg_session_projection
   pkg_tool_present --> 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
@@ -1493,7 +1492,7 @@ flowchart TD
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
-| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
+| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 1 - 2
docs/module-graph.zh.md

@@ -820,7 +820,6 @@ flowchart TD
   pkg_tool_present --> pkg_session_projection
   pkg_tool_present --> 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
@@ -1495,7 +1494,7 @@ flowchart TD
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
-| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
+| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 3011a1ec612f0e77925793e07ab07a567eb78b40
-persistence-catalog.zh.md: c1ca737cf0e1eba772b14dd00c93121b25e1339c
+persistence-catalog.md: 29e5d0a42cf92d0153f7db6911c5ae4ba3175f2f
+persistence-catalog.zh.md: fc06d73f18e31333f540ba8630f5029eaeb46b32

+ 11 - 61
docs/persistence-catalog.md

@@ -77,7 +77,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
 | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
 | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
 | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
-| `event:workspace/changes` | event | `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
+| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
 
 ## Event envelope
 
@@ -1248,13 +1248,15 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/
 #### `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
+/**
+ * A completed top-level turn's changed files were summarized; the summary itself stays on the
+ * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
+ * lives. The latest event for one turn replaces earlier ones.
+ */
+'workspace/changes': { turn: number }
 ```
 
-Types: [WorkspaceChangesData](subsystems/deliverables.md)
-
-Source: [`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts)
+Source: [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts)
 
 ## Resolved persistence types
 
@@ -3918,7 +3920,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
 
 SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
 
-Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -4043,37 +4045,16 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:71`](../packages/web
 
 ### `event:workspace/changes`
 
-SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061`
+SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72`
 
 | Property | Presence | Type |
 |---|---|---|
-| `data` | required | [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata) |
+| `data` | required | [`event:turn/start.data`](#persistence-type-eventturnstartdata) |
 | `ignorable` | optional | `true` |
 | `seq` | required | `number` |
 | `time` | required | `number` |
 | `type` | required | `"workspace/changes"` |
 
-<a id="persistence-type-eventworkspacechangesdatafiles"></a>
-
-### `event:workspace/changes.data.files`
-
-SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927`
-
-Array of [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile).
-
-<a id="persistence-type-eventworkspacechangesdatasnapshot"></a>
-
-### `event:workspace/changes.data.snapshot`
-
-SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7`
-
-Sources: [`packages/deliverables/workspace-changes/src/types.ts:30`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| Property | Presence | Type |
-|---|---|---|
-| `after` | required | `string` |
-| `before` | required | `string` |
-
 <a id="persistence-type-every"></a>
 
 ### `"every"`
@@ -4734,37 +4715,6 @@ Sources: [`packages/deliverables/tool-present/src/types.ts:5`](../packages/deliv
 | `description` | optional | `string` |
 | `path` | required | `string` |
 
-<a id="persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile"></a>
-
-### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`
-
-SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1`
-
-Sources: [`packages/deliverables/workspace-changes/src/types.ts:4`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| Property | Presence | Type |
-|---|---|---|
-| `added` | required | `number` |
-| `binary` | optional | `true` |
-| `deleted` | required | `number` |
-| `display` | required | `string` |
-| `path` | required | `string` |
-
-<a id="persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata"></a>
-
-### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`
-
-SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2`
-
-Sources: [`packages/deliverables/workspace-changes/src/types.ts:22`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| Property | Presence | Type |
-|---|---|---|
-| `files` | required | [`event:workspace/changes.data.files`](#persistence-type-eventworkspacechangesdatafiles) |
-| `snapshot` | required | [`event:workspace/changes.data.snapshot`](#persistence-type-eventworkspacechangesdatasnapshot) |
-| `total` | required | `number` |
-| `turn` | required | `number` |
-
 <a id="persistence-type-packagesexperimentalagent-teamsrctypeststeammemberphase"></a>
 
 ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase`

+ 11 - 61
docs/persistence-catalog.zh.md

@@ -79,7 +79,7 @@
 | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
 | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
 | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
-| `event:workspace/changes` | event | `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
+| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) |
 
 ## 事件信封
 
@@ -1250,13 +1250,15 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 #### `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
+/**
+ * A completed top-level turn's changed files were summarized; the summary itself stays on the
+ * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
+ * lives. The latest event for one turn replaces earlier ones.
+ */
+'workspace/changes': { turn: number }
 ```
 
-类型:[WorkspaceChangesData](subsystems/deliverables.zh.md)
-
-来源:[`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts)
+来源:[`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts)
 
 ## 已解析的持久化类型
 
@@ -3920,7 +3922,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
 
 SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
 
-来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -4045,37 +4047,16 @@ SHA-256: `2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3`
 
 ### `event:workspace/changes`
 
-SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061`
+SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72`
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
-| `data` | 必需 | [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata) |
+| `data` | 必需 | [`event:turn/start.data`](#persistence-type-eventturnstartdata) |
 | `ignorable` | 可选 | `true` |
 | `seq` | 必需 | `number` |
 | `time` | 必需 | `number` |
 | `type` | 必需 | `"workspace/changes"` |
 
-<a id="persistence-type-eventworkspacechangesdatafiles"></a>
-
-### `event:workspace/changes.data.files`
-
-SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927`
-
-[`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile) 的数组。
-
-<a id="persistence-type-eventworkspacechangesdatasnapshot"></a>
-
-### `event:workspace/changes.data.snapshot`
-
-SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7`
-
-来源:[`packages/deliverables/workspace-changes/src/types.ts:30`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| 属性 | 存在性 | 类型 |
-|---|---|---|
-| `after` | 必需 | `string` |
-| `before` | 必需 | `string` |
-
 <a id="persistence-type-every"></a>
 
 ### `"every"`
@@ -4736,37 +4717,6 @@ SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97`
 | `description` | 可选 | `string` |
 | `path` | 必需 | `string` |
 
-<a id="persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile"></a>
-
-### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`
-
-SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1`
-
-来源:[`packages/deliverables/workspace-changes/src/types.ts:4`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| 属性 | 存在性 | 类型 |
-|---|---|---|
-| `added` | 必需 | `number` |
-| `binary` | 可选 | `true` |
-| `deleted` | 必需 | `number` |
-| `display` | 必需 | `string` |
-| `path` | 必需 | `string` |
-
-<a id="persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata"></a>
-
-### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`
-
-SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2`
-
-来源:[`packages/deliverables/workspace-changes/src/types.ts:22`](../packages/deliverables/workspace-changes/src/types.ts)
-
-| 属性 | 存在性 | 类型 |
-|---|---|---|
-| `files` | 必需 | [`event:workspace/changes.data.files`](#persistence-type-eventworkspacechangesdatafiles) |
-| `snapshot` | 必需 | [`event:workspace/changes.data.snapshot`](#persistence-type-eventworkspacechangesdatasnapshot) |
-| `total` | 必需 | `number` |
-| `turn` | 必需 | `number` |
-
 <a id="persistence-type-packagesexperimentalagent-teamsrctypeststeammemberphase"></a>
 
 ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase`

+ 2 - 2
docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-14-workspace-changes-event.md
-2026-09-14-workspace-changes-event.md: e204be8e8b3dbad37bee2b9aa428b8cfa1664d74
-2026-09-14-workspace-changes-event.zh.md: 7f7b92a2e0e91e27944f0b858edfc5e8a3a2314e
+2026-09-14-workspace-changes-event.md: 5d8a4ff6526dc67444d5dce0076b005d96b3b059
+2026-09-14-workspace-changes-event.zh.md: fa2d037dc50dae2bfb7ba4b3801558559b73fd7c

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

@@ -28,7 +28,7 @@ baseline: false
 changes:
   - root: "event:workspace/changes"
     previous: null
-    after: "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061"
+    after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"
     decision: same-version
 ```
 

+ 6 - 76
docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json

@@ -6,7 +6,7 @@
       "kind": "event",
       "event": "workspace/changes",
       "surface": false,
-      "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061",
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
       "schema": {
         "root": 0,
         "nodes": [
@@ -20,83 +20,33 @@
               },
               {
                 "name": "ignorable",
-                "type": 5,
+                "type": 3,
                 "optional": true
               },
               {
                 "name": "seq",
-                "type": 4,
+                "type": 2,
                 "optional": false
               },
               {
                 "name": "time",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "type",
-                "type": 8,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "files",
                 "type": 2,
                 "optional": false
               },
               {
-                "name": "snapshot",
-                "type": 7,
-                "optional": false
-              },
-              {
-                "name": "total",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "turn",
+                "name": "type",
                 "type": 4,
                 "optional": false
               }
             ],
             "indices": []
           },
-          {
-            "kind": "array",
-            "element": 3
-          },
           {
             "kind": "object",
             "properties": [
               {
-                "name": "added",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 5,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 6,
+                "name": "turn",
+                "type": 2,
                 "optional": false
               }
             ],
@@ -110,26 +60,6 @@
             "kind": "literal",
             "value": true
           },
-          {
-            "kind": "primitive",
-            "type": "string"
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "after",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "before",
-                "type": 6,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
           {
             "kind": "literal",
             "value": "workspace/changes"

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

@@ -28,7 +28,7 @@ baseline: false
 changes:
   - root: "event:workspace/changes"
     previous: null
-    after: "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061"
+    after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"
     decision: same-version
 ```
 

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

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

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

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

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

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

+ 76 - 467
docs/persistence-schema.json

@@ -13748,7 +13748,7 @@
       "kind": "event",
       "event": "workspace/changes",
       "surface": false,
-      "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061",
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
       "schema": {
         "root": 0,
         "nodes": [
@@ -13762,83 +13762,33 @@
               },
               {
                 "name": "ignorable",
-                "type": 5,
+                "type": 3,
                 "optional": true
               },
               {
                 "name": "seq",
-                "type": 4,
+                "type": 2,
                 "optional": false
               },
               {
                 "name": "time",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "type",
-                "type": 8,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "files",
                 "type": 2,
                 "optional": false
               },
               {
-                "name": "snapshot",
-                "type": 7,
-                "optional": false
-              },
-              {
-                "name": "total",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "turn",
+                "name": "type",
                 "type": 4,
                 "optional": false
               }
             ],
             "indices": []
           },
-          {
-            "kind": "array",
-            "element": 3
-          },
           {
             "kind": "object",
             "properties": [
               {
-                "name": "added",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 5,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 6,
+                "name": "turn",
+                "type": 2,
                 "optional": false
               }
             ],
@@ -13852,26 +13802,6 @@
             "kind": "literal",
             "value": true
           },
-          {
-            "kind": "primitive",
-            "type": "string"
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "after",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "before",
-                "type": 6,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
           {
             "kind": "literal",
             "value": "workspace/changes"
@@ -14722,142 +14652,6 @@
       "names": [],
       "sources": []
     },
-    {
-      "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061",
-      "schema": {
-        "root": 0,
-        "nodes": [
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "data",
-                "type": 1,
-                "optional": false
-              },
-              {
-                "name": "ignorable",
-                "type": 5,
-                "optional": true
-              },
-              {
-                "name": "seq",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "time",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "type",
-                "type": 8,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "files",
-                "type": 2,
-                "optional": false
-              },
-              {
-                "name": "snapshot",
-                "type": 7,
-                "optional": false
-              },
-              {
-                "name": "total",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "turn",
-                "type": 4,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "array",
-            "element": 3
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "added",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 5,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 6,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "primitive",
-            "type": "number"
-          },
-          {
-            "kind": "literal",
-            "value": true
-          },
-          {
-            "kind": "primitive",
-            "type": "string"
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "after",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "before",
-                "type": 6,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "literal",
-            "value": "workspace/changes"
-          }
-        ]
-      },
-      "names": [
-        "event:workspace/changes"
-      ],
-      "sources": []
-    },
     {
       "digest": "0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3",
       "schema": {
@@ -18045,63 +17839,6 @@
       "names": [],
       "sources": []
     },
-    {
-      "digest": "14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1",
-      "schema": {
-        "root": 0,
-        "nodes": [
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "added",
-                "type": 1,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 2,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 1,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 3,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 3,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "primitive",
-            "type": "number"
-          },
-          {
-            "kind": "literal",
-            "value": true
-          },
-          {
-            "kind": "primitive",
-            "type": "string"
-          }
-        ]
-      },
-      "names": [
-        "packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile"
-      ],
-      "sources": [
-        "packages/deliverables/workspace-changes/src/types.ts:4"
-      ]
-    },
     {
       "digest": "15196447222782e773eb943c92b18316ce96b9af0f0cfddb6e57ba8274ecc5ff",
       "schema": {
@@ -28324,65 +28061,6 @@
         "packages/feedback/message-feedback/src/types.ts:17"
       ]
     },
-    {
-      "digest": "39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927",
-      "schema": {
-        "root": 0,
-        "nodes": [
-          {
-            "kind": "array",
-            "element": 1
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "added",
-                "type": 2,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 3,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 2,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 4,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 4,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "primitive",
-            "type": "number"
-          },
-          {
-            "kind": "literal",
-            "value": true
-          },
-          {
-            "kind": "primitive",
-            "type": "string"
-          }
-        ]
-      },
-      "names": [
-        "event:workspace/changes.data.files"
-      ],
-      "sources": []
-    },
     {
       "digest": "3aa261c8516b5c955ed12080e6257a56cbe513f6c9f0ac9274c12e96c196ebca",
       "schema": {
@@ -38329,109 +38007,6 @@
       "names": [],
       "sources": []
     },
-    {
-      "digest": "69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2",
-      "schema": {
-        "root": 0,
-        "nodes": [
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "files",
-                "type": 1,
-                "optional": false
-              },
-              {
-                "name": "snapshot",
-                "type": 6,
-                "optional": false
-              },
-              {
-                "name": "total",
-                "type": 3,
-                "optional": false
-              },
-              {
-                "name": "turn",
-                "type": 3,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "array",
-            "element": 2
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "added",
-                "type": 3,
-                "optional": false
-              },
-              {
-                "name": "binary",
-                "type": 4,
-                "optional": true
-              },
-              {
-                "name": "deleted",
-                "type": 3,
-                "optional": false
-              },
-              {
-                "name": "display",
-                "type": 5,
-                "optional": false
-              },
-              {
-                "name": "path",
-                "type": 5,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "primitive",
-            "type": "number"
-          },
-          {
-            "kind": "literal",
-            "value": true
-          },
-          {
-            "kind": "primitive",
-            "type": "string"
-          },
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "after",
-                "type": 5,
-                "optional": false
-              },
-              {
-                "name": "before",
-                "type": 5,
-                "optional": false
-              }
-            ],
-            "indices": []
-          }
-        ]
-      },
-      "names": [
-        "packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData"
-      ],
-      "sources": [
-        "packages/deliverables/workspace-changes/src/types.ts:22"
-      ]
-    },
     {
       "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7",
       "schema": {
@@ -58854,6 +58429,72 @@
       "names": [],
       "sources": []
     },
+    {
+      "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
+      "schema": {
+        "root": 0,
+        "nodes": [
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "data",
+                "type": 1,
+                "optional": false
+              },
+              {
+                "name": "ignorable",
+                "type": 3,
+                "optional": true
+              },
+              {
+                "name": "seq",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "time",
+                "type": 2,
+                "optional": false
+              },
+              {
+                "name": "type",
+                "type": 4,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "object",
+            "properties": [
+              {
+                "name": "turn",
+                "type": 2,
+                "optional": false
+              }
+            ],
+            "indices": []
+          },
+          {
+            "kind": "primitive",
+            "type": "number"
+          },
+          {
+            "kind": "literal",
+            "value": true
+          },
+          {
+            "kind": "literal",
+            "value": "workspace/changes"
+          }
+        ]
+      },
+      "names": [
+        "event:workspace/changes"
+      ],
+      "sources": []
+    },
     {
       "digest": "e31572180d6b28b73f073ad30ae19d5d82982d3daf984723984e23da1e7c459a",
       "schema": {
@@ -60317,40 +59958,6 @@
         "packages/llm/llm/src/types.ts:381"
       ]
     },
-    {
-      "digest": "f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7",
-      "schema": {
-        "root": 0,
-        "nodes": [
-          {
-            "kind": "object",
-            "properties": [
-              {
-                "name": "after",
-                "type": 1,
-                "optional": false
-              },
-              {
-                "name": "before",
-                "type": 1,
-                "optional": false
-              }
-            ],
-            "indices": []
-          },
-          {
-            "kind": "primitive",
-            "type": "string"
-          }
-        ]
-      },
-      "names": [
-        "event:workspace/changes.data.snapshot"
-      ],
-      "sources": [
-        "packages/deliverables/workspace-changes/src/types.ts:30"
-      ]
-    },
     {
       "digest": "f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f",
       "schema": {
@@ -61086,10 +60693,12 @@
         ]
       },
       "names": [
-        "event:turn/start.data"
+        "event:turn/start.data",
+        "event:workspace/changes.data"
       ],
       "sources": [
-        "packages/core/session/src/types.ts:276"
+        "packages/core/session/src/types.ts:276",
+        "packages/deliverables/workspace-changes/src/types.ts:52"
       ]
     },
     {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/README.md
-README.md: 9ee98bfa5f0d468cde8e11dad22567b05de9e41f
-README.zh.md: 22e3cd76801354228d00742a2698929e319948cb
+README.md: a1be9608cae9331e5b97f1215bf10c40dcb2e5e7
+README.zh.md: c57d125d3f8b19c358cbf16f6bc701dfc103edb6

+ 1 - 1
docs/subsystems/README.md

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

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

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

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/deliverables.md
-deliverables.md: f8e2b34d0f57537a8cc2e97331f1bf04525ddb4e
-deliverables.zh.md: da8d7a93d282ab4a0bd02f5e5eadcd4a0f75703b
+deliverables.md: 22b51978fddc44bc40083bcbd501aec19a380937
+deliverables.zh.md: 67774eb4ba8c91e030ef20e228557276ff73d48b

+ 52 - 8
docs/subsystems/deliverables.md

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

+ 52 - 8
docs/subsystems/deliverables.zh.md

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

+ 2 - 2
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: 89f9e683d18928b449b6336fabf1909284f7e371
-README.zh.md: cbfc98a428e4037ec1c65523c6574b1e42af94da
+README.md: 340a4c8a0dc6e3d99e71973b5ecfe13560716422
+README.zh.md: 4decf7f247fe33c5b8d4ed92f2f05925e3897df0

+ 4 - 4
packages/client/ui-deliverables/README.md

@@ -25,7 +25,7 @@ This package renders the changed-files card a finished turn ends with — the fi
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a recorded summary — a workspace outside any git repository, no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain.
+Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a workspace outside any git repository, no git on the Host, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain.
 
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
@@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status;
 
 ### The changed-files card
 
-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 show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
+The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
 
 ### Inline-code links
 
@@ -50,7 +50,7 @@ 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 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.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
 
 Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the 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.
 
@@ -63,7 +63,7 @@ Native opening uses an authenticated POST addressed by the viewed Session, event
 
 Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary.
 
-- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records the summary the card renders.
+- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders.
 - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
 - [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).

+ 4 - 4
packages/client/ui-deliverables/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——工作区不在任何 git 仓库内、Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。
+与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——工作区不在任何 git 仓库内、Host 上没有 git、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接。
 
 <a id="explicit-deliveries"></a>
 ### 显式交付
@@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 改动文件卡片
 
-卡片渲染本轮最新的 `workspace/changes` 事件:标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
+卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
 
 ### 行内代码链接
 
@@ -50,7 +50,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 事件折叠进 `DeliverablesTurnData.changes`,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
 
 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。Host 读取声明或摘要及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
@@ -63,7 +63,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。
 
-- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录卡片所渲染摘要的 Host 插件。
+- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。
 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
 - [本轮改动文件卡片](../../../.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)取代。

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

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

+ 3 - 3
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -4,11 +4,10 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
 import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { changedFileUrl } from '../changes.ts'
+import { changedFileUrl, type ChangesSummary } from '../changes.ts'
 import type { PresentedHost } from '../presented.ts'
 import { IconCodeBracketsOutline16 } from './icons.tsx'
 import type { PresentedOpenPhase } from './present-open.ts'
-import type { ChangesTurnData } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
 import css from './ChangedFiles.module.css'
 
@@ -51,7 +50,8 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop
  * @returns the card.
  */
 export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: {
-  changes: ChangesTurnData
+  /** The served summary with the sequence of the event that announced it. */
+  changes: Pick<ChangesSummary, 'files' | 'total'> & { seq: number }
   cwd: string | undefined
   sessionId: SessionId
   host: PresentedHost | null

+ 25 - 9
packages/client/ui-deliverables/src/client/Deliverables.tsx

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

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

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

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

@@ -14,6 +14,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { ChangesSummaryStore } from './changes-summary.ts'
 import { PresentedOpenController } from './present-open.ts'
 import { PresentRow } from './PresentRow.tsx'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
@@ -38,8 +39,12 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se
  */
 export function apply(ctx: ClientContext): void {
   const opener = new PresentedOpenController()
-  ctx.effect(() => () => opener.dispose())
-  ctx.on('connection/reset', () => { opener.resetHost() })
+  const summaries = new ChangesSummaryStore()
+  ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose()]))
+  ctx.on('connection/reset', () => {
+    opener.resetHost()
+    summaries.reset()
+  })
   ctx.uiConversation.events.register(deliverablesDefinition)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
   ctx.slots.inject(
@@ -49,8 +54,9 @@ export function apply(ctx: ClientContext): void {
       select: selectDeliverables,
       locale: NS,
       inject: (): DeliverablesInjected => ({
-        hooks: { presentedOpen: opener.state, presentedHost: opener.host },
+        hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state },
         reloadPresentedHost: () => opener.loadHost(),
+        loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
         openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
         openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
       }),

+ 7 - 14
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -9,8 +9,7 @@ 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 { isChangesEvent } from '../changes.ts'
 import { basename, isPresentedData, isPresentedFile } from '../presented.ts'
 
 /** A declared file with its authorized open coordinates. */
@@ -24,11 +23,9 @@ interface ProducedPath {
   readonly path: string
 }
 
-/** The latest recorded change summary of one Turn with its open coordinates. */
+/** The latest `workspace/changes` announcement of one Turn; the Host serves its summary by this sequence. */
 export interface ChangesTurnData {
   readonly seq: number
-  readonly files: readonly WorkspaceChangedFile[]
-  readonly total: number
 }
 
 /** Immutable produced-file facts published against one Turn. */
@@ -171,7 +168,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
     if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
     if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null
-    if (event.type === 'workspace/changes') return isChangesData(event.data) ? { id: String(event.data.turn), role: 'update' } : null
+    if (event.type === 'workspace/changes') return isChangesEvent(event.data) ? { id: String(event.data.turn), role: 'update' } : null
     if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
       return { id: String(event.data.turn), role: 'update' }
     }
@@ -182,10 +179,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     return { turn: match.event.data.turn, calls: new Map(), produced: [] }
   },
   update: (context, match) => {
-    if (match.event.type === 'workspace/changes') {
-      const { files, total } = match.event.data
-      return { ...context.state, changes: { seq: match.event.seq, files, total } }
-    }
+    if (match.event.type === 'workspace/changes') return { ...context.state, changes: { seq: match.event.seq } }
     if (match.event.type === 'deliverables/presented') {
       const { files } = match.event.data
       const seq = match.event.seq
@@ -236,13 +230,12 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
 }
 
 /**
- * The turn's recorded change summary when it lists at least one file.
+ * The turn's latest change announcement.
  * @param owner - closing turn.
- * @returns the latest summary, or null when the Host recorded none or an empty one.
+ * @returns the announcement, or null when the Host recorded none.
  */
 export function changesForClosing(owner: TurnTailOwnerProps): ChangesTurnData | null {
-  const changes = owner.turn.data.get('deliverables')?.changes
-  return changes === undefined || changes.files.length === 0 ? null : changes
+  return owner.turn.data.get('deliverables')?.changes ?? null
 }
 
 /**

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

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

+ 27 - 12
packages/client/ui-deliverables/src/present-open.ts

@@ -1,4 +1,4 @@
-/** Open declared or changed workspace paths verified by the viewed Session's filesystem. */
+/** Serve change summaries and open declared or changed workspace paths verified by the viewed Session's filesystem. */
 import { dirname, isAbsolute, relative, resolve } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
@@ -10,13 +10,14 @@ 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 { CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts'
 import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts'
 
 /**
- * Register native opening inside Connection's authentication fence: desktop
- * metadata, declared-file actions, and changed-file or folder opening.
- * @param ctx - Session lookup, native opener, and route lifetime.
+ * Register the deliverables routes inside Connection's authentication fence:
+ * desktop metadata, change summaries, declared-file actions, and changed-file
+ * or folder opening.
+ * @param ctx - Session lookup, change summaries, native opener, and route lifetime.
  */
 export function registerPresentOpen(ctx: Context): void {
   ctx.connection.fetch.register({
@@ -24,6 +25,10 @@ export function registerPresentOpen(ctx: Context): void {
     fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost,
       { headers: { 'cache-control': 'no-store' } })),
   })
+  ctx.connection.fetch.register({
+    path: CHANGED_FILES_PATH, methods: ['GET'], requestBody: 'buffered',
+    fetch: request => Promise.resolve(handleChangesSummary(ctx, request)),
+  })
   const lifetime = new AbortController()
   const pending = new Set<Promise<Response>>()
   ctx.effect(() => async () => {
@@ -135,6 +140,18 @@ export function commonChangedFolder(cwd: string, files: readonly WorkspaceChange
   return rel.startsWith('..') || isAbsolute(rel) ? cwd : common
 }
 
+/** The summary one `workspace/changes` event announced, without the Host working directory; 404 once the Host no longer serves it. */
+function handleChangesSummary(ctx: Context, request: Request): Response {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = coordinate(query.get('seq'))
+  if (!id || seq === undefined) return new Response('Invalid change summary coordinates.', { status: 400 })
+  const summary = ctx.workspaceChanges.summary(id as SessionId, seq)
+  if (summary === undefined) return new Response('Change summary unavailable.', { status: 404 })
+  const { turn, files, total } = summary
+  return Response.json({ turn, files, total } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } })
+}
+
 async function handleChangesOpen(ctx: Context, request: Request): Promise<Response> {
   const query = new URL(request.url).searchParams
   const id = query.get('sessionId')
@@ -143,13 +160,11 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise<Respon
   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 (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
+    const changes = ctx.workspaceChanges.summary(id as SessionId, seq)
+    if (changes === undefined) return new Response('Change summary unavailable.', { status: 404 })
+    const workspaceRoot = changes.cwd
     if (index === null) {
       const folder = commonChangedFolder(workspaceRoot, changes.files)
       const target = await ctx.fs.resolve(folder, { signal: request.signal })
@@ -157,7 +172,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise<Respon
       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 })
+    if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 })
     const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal)
     return await openVerified(ctx, request, path, 'open')
   } catch (error: unknown) {

+ 47 - 31
packages/client/ui-deliverables/tests/changes-open.host.spec.ts

@@ -1,4 +1,4 @@
-/** Changed-file and common-folder native opens resolve the viewed Session's current workspace. */
+/** The change summary route and the changed-file and common-folder native opens over the Host-served summaries. */
 import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
@@ -8,13 +8,12 @@ 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 type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts'
-import { changedFileUrl, CHANGES_OPEN_PATH, isChangedFile, isChangesData } from '../src/changes.ts'
+import { changedFileUrl, changesSummaryUrl, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesEvent, isChangesSummary } from '../src/changes.ts'
 
 const cleanups: Array<() => Promise<unknown>> = []
 afterEach(async () => {
@@ -34,31 +33,51 @@ async function fixture() {
   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 data: WorkspaceChangesSummary = { turn: 1, cwd, total: 3, 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 }
+  const readEvent = vi.fn(async (_request: SessionEventReadRequest) => {
+    throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
   })
   ctx.provide('sessionQuery', { readEvent } as never)
+  const summary = vi.fn((sessionId: SessionId, seq: number) => sessionId === 'owner' && seq === 9 ? data : undefined)
+  ctx.provide('workspaceChanges', { summary })
   const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
   ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
   const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
-  await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen })
+  await ctx.plugin({
+    inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'],
+    apply: registerPresentOpen,
+  })
   const handler = connection.createSharedFetchHandler('/api')
   const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
-  return { root, cwd, ctx, data, session, readEvent, open, opener, outside }
+  const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`))
+  return { root, cwd, ctx, data, readEvent, open, read, opener, outside, summary }
 }
 
+describe('change summary route', () => {
+  it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => {
+    const { read, data, summary } = await fixture()
+    expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`)
+    const response = await read()
+    expect(response.status).toBe(200)
+    expect(response.headers.get('cache-control')).toBe('no-store')
+    expect(await response.json()).toEqual({ turn: 1, total: 3, files: data.files })
+    expect((await read('?sessionId=owner&seq=8')).status).toBe(404)
+    expect((await read('?sessionId=other&seq=9')).status).toBe(404)
+    for (const bad of ['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=x', '?sessionId=owner&seq=1.5']) {
+      expect((await read(bad)).status).toBe(400)
+    }
+    expect(summary).toHaveBeenCalledTimes(3)
+  })
+})
+
 describe('changed files native open route', () => {
   it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
     const { cwd, open, opener, outside } = await fixture()
@@ -92,17 +111,12 @@ describe('changed files native open route', () => {
       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()
+  it('refuses unrelated Sessions, forgotten summaries, unknown indices, and missing files', async () => {
+    const { open, readEvent, opener, cwd } = await fixture()
     expect((await open('?sessionId=other&seq=9&index=0')).status).toBe(404)
     expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
     expect((await open('?sessionId=owner&seq=9&index=5')).status).toBe(404)
-    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)
-    }
+    expect(readEvent).not.toHaveBeenCalled()
     await unlink(join(cwd, 'src', 'lib', 'a.ts'))
     expect((await open()).status).toBe(404)
     await rm(join(cwd, 'src'), { recursive: true })
@@ -126,22 +140,24 @@ describe('changed files native open route', () => {
     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', () => {
+  it('validates served summaries and logged announcements', () => {
     expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true)
     expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false)
     expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false)
     expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false)
     expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false)
     expect(isChangedFile([])).toBe(false)
-    expect(isChangesData({ turn: 1, total: 0, files: [] })).toBe(true)
-    expect(isChangesData({ turn: '1', total: 0, files: [] })).toBe(false)
-    expect(isChangesData([])).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 0, files: [] })).toBe(true)
+    expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a', display: 'a', added: 1, deleted: 0 }] })).toBe(true)
+    expect(isChangesSummary({ turn: '1', total: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 0, total: 0, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 1.5, files: [] })).toBe(false)
+    expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a' }] })).toBe(false)
+    expect(isChangesSummary([])).toBe(false)
+    expect(isChangesEvent({ turn: 1 })).toBe(true)
+    expect(isChangesEvent({ turn: 1, extra: true })).toBe(true)
+    expect(isChangesEvent({ turn: 0 })).toBe(false)
+    expect(isChangesEvent({ turn: '1' })).toBe(false)
+    expect(isChangesEvent(null)).toBe(false)
   })
 })

+ 91 - 21
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -23,22 +23,27 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
+import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
+import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
 import { PresentedOpenController } from '../src/client/present-open.ts'
 import {
   basename, changesForClosing, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing,
-  selectProducedFiles, type ChangesTurnData, type DeliverablesTurnData,
+  selectProducedFiles, type DeliverablesTurnData,
 } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
 import { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
-function openProps(controller = new PresentedOpenController()) {
+function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) {
   controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
   const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
   return {
     useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
     reloadPresentedHost: vi.fn(() => controller.loadHost()),
+    useChangesSummary: <T,>(select: (state: ReturnType<typeof summaries.state.getSnapshot>) => T): T =>
+      select(summaries.state.getSnapshot()),
+    loadChangesSummary: vi.fn((...args: Parameters<ChangesSummaryStore['load']>) => summaries.load(...args)),
     usePresentedHost: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
       select(controller.host.getSnapshot()),
     openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
@@ -428,30 +433,28 @@ describe('produced-file Turn data', () => {
 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 } : {} })
 
-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 changesEvent = (seq: number, turn = 1) => at(seq, 'workspace/changes', { turn })
 
-describe('recorded changes Turn data', () => {
-  it('keeps the latest valid summary of the turn and ignores malformed or empty ones for the card', () => {
+describe('announced changes Turn data', () => {
+  it('keeps the latest valid announcement of the turn and ignores malformed ones', () => {
     const value = assembler([
       at(1, 'turn/start', { turn: 1 }),
-      changesEvent(2, [changedFile('old.ts')]),
-      at(3, 'workspace/changes', { turn: 1, files: [{ path: 'x' }], total: 1 }),
+      changesEvent(2),
+      at(3, 'workspace/changes', { turn: 'x' }),
       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),
+      changesEvent(5),
       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(owner)).toEqual({ seq: 5 })
+    expect(selectDeliverables(owner)).toEqual({ changes: { seq: 5 }, presented: [] })
     expect(changesForClosing(tailOwner(deliverablesOf(value, 2), 8))).toBeNull()
     expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 8))).toBeNull()
     expect(changesForClosing(tailOwner(undefined, 8))).toBeNull()
   })
 
   it('preserves Turn data identity across unrelated appends', () => {
-    const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2, [changedFile('a.ts')])])
+    const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2)])
     const first = deliverablesOf(value)
     value.append(call(3, 'later', 'read', { file_path: 'a.ts' }))
     value.flush()
@@ -465,16 +468,76 @@ describe('ChangedFiles card', () => {
     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 }
+  const changes = { seq: 5 }
+  const served: ChangesSummary = { turn: 1, files, total: 11 }
+
+  /** A store already holding the summary the Host served for the announced sequence. */
+  function servedStore(summary: ChangesSummary = served, seq = 5) {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [changesSummaryUrl(SessionId('child-session'), seq)]: summary })
+    return summaries
+  }
 
-  function renderCard(controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }) {
-    const props = openProps(controller)
+  function renderCard(
+    controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(),
+  ) {
+    const props = openProps(controller, summaries)
     props.openChanged.mockResolvedValue(undefined)
     const openFile = vi.fn<(path: string) => void>()
     const view = render(<Deliverables {...props} matched={matched} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(locale)} />)
     return { props, openFile, view }
   }
 
+  it('reads the announced summary once and renders nothing while it loads, when it is gone, or when it lists no file', async () => {
+    const summaries = new ChangesSummaryStore()
+    const fetchMock = vi.fn(async (input: string | URL | Request) => {
+      const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
+      if (url.endsWith('seq=5')) return Response.json(served)
+      if (url.endsWith('seq=6')) return Response.json({ turn: 1, files: [], total: 0 })
+      return new Response('gone', { status: 404 })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const { props, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] as never[] }, summaries)
+    expect(view.container.querySelector('[data-changed-files]')).toBeNull()
+    expect(props.loadChangesSummary).toHaveBeenCalledWith('child-session', 5)
+    await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toEqual(served) })
+    view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+    expect(view.getByText('Edited 11 files')).toBeTruthy()
+    for (const seq of [6, 7]) {
+      view.rerender(<Deliverables {...props} matched={{ changes: { seq }, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+      await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), seq)]).not.toBe('loading') })
+      view.rerender(<Deliverables {...props} matched={{ changes: { seq }, presented: [] }} openFile={() => {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+      expect(view.container.querySelector('[data-changed-files]')).toBeNull()
+    }
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 7)]).toBe('missing')
+    await summaries.load(SessionId('child-session'), 5)
+    expect(fetchMock).toHaveBeenCalledTimes(3)
+    // A replaced connection forgets every read; a later mount asks the new Host again.
+    summaries.reset()
+    expect(summaries.state.getSnapshot()).toEqual({})
+    fetchMock.mockRejectedValueOnce(new Error('offline'))
+    await summaries.load(SessionId('child-session'), 5)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('missing')
+    fetchMock.mockResolvedValueOnce(Response.json({ turn: 'x' }))
+    await summaries.load(SessionId('child-session'), 8)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 8)]).toBe('missing')
+    await summaries.dispose()
+    await summaries.load(SessionId('child-session'), 9)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 9)]).toBeUndefined()
+  })
+
+  it('drops a read that settles after disposal', async () => {
+    const summaries = new ChangesSummaryStore()
+    let settle!: (response: Response) => void
+    vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((resolve) => { settle = resolve })))
+    const loading = summaries.load(SessionId('child-session'), 5)
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading')
+    const disposal = summaries.dispose()
+    settle(Response.json(served))
+    await Promise.all([loading, disposal])
+    expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading')
+  })
+
   it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => {
     const { props, openFile, view } = renderCard()
     const card = view.container.querySelector('[data-changed-files]')
@@ -555,8 +618,8 @@ describe('ChangedFiles card', () => {
   })
 
   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[] })
+    const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2 })
+    const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short)
     expect(view.getByText('Edited 2 files')).toBeTruthy()
     expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull()
     expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1)
@@ -653,8 +716,13 @@ describe('plugin registration', () => {
     fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
     await face.reloadPresentedHost()
     expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' })
+    fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0 }))
+    await face.loadChangesSummary(SessionId('child-session'), 5)
+    expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0 })
     ctx.emit('connection/reset')
     expect(face.hooks.presentedHost.getSnapshot()).toBeNull()
+    // The replaced connection may reach a Host that no longer serves the summaries read so far.
+    expect(face.hooks.changesSummary.getSnapshot()).toEqual({})
     await face.openPresented(SessionId('child-session'), 2, 0)
     expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
     await face.openChanged(SessionId('child-session'), 5, null)
@@ -741,11 +809,13 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] },
     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 }),
+    at(5, 'workspace/changes', { turn: 1 }),
   ])
   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)} />)
+  const summaries = new ChangesSummaryStore()
+  summaries.state.set({ [changesSummaryUrl(SessionId('session'), 5)]: { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1 } })
+  const view = render(<Deliverables {...openProps(new PresentedOpenController(), summaries)} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   expect(view.getByText('Edited 1 files')).toBeTruthy()
   expect(view.queryByText('Deliverables')).toBeNull()
 })
@@ -817,7 +887,7 @@ it('loads desktop information once the tail renders and not again while it is kn
   controller.host.set(null)
   props.reloadPresentedHost.mockResolvedValue(undefined)
   const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) }
-  const view = render(<Deliverables {...shared} matched={{ changes: { seq: 2, total: 1, files: [{ path: 'a.ts', display: 'a.ts', added: 1, deleted: 0 }] }, presented: [] }} />)
+  const view = render(<Deliverables {...shared} matched={{ changes: { seq: 2 }, presented: [] }} />)
   expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
   controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
   view.rerender(<Deliverables {...shared} matched={{ changes: null, presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)

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

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

+ 2 - 2
packages/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/deliverables/README.md
-README.md: ff2980f8cd190281010fcce3cd5c8a27f7d4213d
-README.zh.md: c9bf60f3e765214608ed4d1d1fc25b1a2115460e
+README.md: 2d3a6908798a2e1565186a016d33d1a806f256d7
+README.zh.md: b9d000c923028168ba818ff995a25372f2203a93

+ 1 - 1
packages/deliverables/README.md

@@ -32,7 +32,7 @@ The deliverables family records what a turn hands to the user as durable Session
 <a id="related-documentation"></a>
 ## Related documentation
 
-- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesData` vocabulary and the two durable events.
+- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesSummary` vocabulary, the two durable events, and the summary service.
 - [Web deliverables](../client/ui-deliverables/README.md) — the turn-tail cards and file mentions that render these events.
 - [Present declares workspace source files](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — the delivery decision.
 - [Turn changed-files card](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the snapshot design and coverage rules.

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

@@ -32,7 +32,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取
 <a id="related-documentation"></a>
 ## 相关文档
 
-- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesData` 的词汇和两个持久事件
+- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesSummary` 的词汇、两个持久事件和摘要服务
 - [Web 产出物](../client/ui-deliverables/README.zh.md)——渲染这些事件的轮尾卡片与文件提及。
 - [present 声明工作区源文件](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——交付决策。
 - [本轮改动文件卡片](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计与覆盖规则。

+ 2 - 2
packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md
-README.md: eadde42f30d3909bdce75641f0c73c7f4ec1fa2c
-README.zh.md: a5a49ea5214e01325e4fcfcd01713048c1fada90
+README.md: cf0bc63290f3e4820e21c966642ed7c28c441cc3
+README.zh.md: 3d551c79f62c3ab42bbaf0fc3f1467b783563676

+ 10 - 13
packages/deliverables/workspace-changes/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository requirement, and coverage rules."
+description: "Summarize each top-level turn's changed files from git working-tree snapshots, announce them with a workspace/changes Session event, and serve the summary while the Session lives; configuration, repository requirement, and coverage rules."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ 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. Only a working directory inside a git repository is recorded. The Web changed-files card renders the event; the model never sees it.
+This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts. 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 Session log receives only a `workspace/changes` event naming the turn; the summary itself stays on the Host, served through the `workspaceChanges` service until the Session is disposed. Only a working directory inside a git repository is recorded. The Web changed-files card renders the served summary; the model never sees it.
 
 ## Table of Contents
 
@@ -35,17 +35,15 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the
 
 | Field | Default | Meaning |
 |---|---|---|
-| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds one snapshot object store per repository |
-| `objectStoreMaxBytes` | `1073741824` | Bytes one repository's snapshot object store may hold before it is discarded and restarted empty |
 | `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 |
+| `maxFiles` | `500` | Maximum files carried by one summary; `total` still reports the complete count |
 
-Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a private object store under the Harness home, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. One store serves every Session of a repository; a Session checks its size when it first locates the repository and discards a store over `objectStoreMaxBytes`, which starts empty. 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.
+Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. 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, or from the call's own arguments when the result persists none — a `write` that creates a file and every `str_replace_editor` mutation: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. 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.
+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 `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. A conversation reopened after a Host restart therefore has no card for its earlier turns.
 
 -----
 
@@ -55,11 +53,11 @@ Each file carries a durable `path` — relative to the working directory inside
 <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 and its object store once per Session, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `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 attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read.
+One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `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, the appended event, and the summary kept under the event's sequence. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The 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.
+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, forget the summaries, and remove the temporary directory.
 
-**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.
+**Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns both the summaries and the snapshot trees for its Session's lifetime; no independent observation can diverge from them.
 
 </details>
 
@@ -68,7 +66,7 @@ Git runs through the `subprocess` capability with a scrubbed environment, `GIT_T
 <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.
+- [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that reads the served summary 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, the deferred shadow repository, and rejected alternatives.
 
@@ -85,8 +83,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Snapshot trees survive only until their private store exceeds `objectStoreMaxBytes` and is discarded; the recorded counts survive, the trees needed for a later full-file comparison do not.
-- The store is shared by every Session of one repository. A Session that discards it while another Session is snapshotting makes that other turn warn and record nothing; the next turn starts afresh.
+- Summaries and snapshot trees live only as long as their Session in this Host process; earlier turns of a conversation reopened after a Host restart have no card. This is the decided behavior: a card whose content the Host can no longer open is not shown.
 - Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`.
 - git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory.
 - Edits the user makes during a turn are attributed to that turn.

+ 10 - 13
packages/deliverables/workspace-changes/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库要求与覆盖规则。"
+description: "用 git 工作树快照汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要;配置、仓库要求与覆盖规则。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本插件每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染该事件;模型看不到它。
+本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要本身留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染提供的摘要;模型看不到它。
 
 ## 目录
 
@@ -35,17 +35,15 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录为每个仓库存放一个快照对象库 |
-| `objectStoreMaxBytes` | `1073741824` | 单个仓库的快照对象库允许的字节数,超过后丢弃并从空开始 |
 | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 |
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
-| `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 |
+| `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 |
 
-工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。一个仓库的所有 Session 共用一个对象库;Session 在首次定位仓库时检查其大小,超过 `objectStoreMaxBytes` 的对象库被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
+工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
 
 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
-每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id
+每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。因此 Host 重启后重新打开的对话,先前轮次没有卡片
 
 -----
 
@@ -55,11 +53,11 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`、追加事件,以及按事件序号保存摘要。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
 
-git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。
+git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录
 
-**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,Session 日志拥有记录的摘要,git 对象库拥有快照树;没有独立观察会与它们分歧。
+**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要与快照树;没有独立观察会与它们分歧。
 
 </details>
 
@@ -68,7 +66,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 <a id="further-exploration"></a>
 ## 进一步探索
 
-- [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。
+- [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)——快照设计、覆盖规则、暂缓的影子仓库与被否决的备选方案。
 
@@ -85,8 +83,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。
-- 对象库由一个仓库的所有 Session 共用。一个 Session 在另一个 Session 快照途中丢弃它,会让那一轮告警并且不记录;下一轮重新开始。
+- 摘要与快照树只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片。这是既定行为:Host 已经打不开内容的卡片不显示。
 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。
 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。
 - 用户在轮次进行中自己做的编辑会被算到该轮。

+ 0 - 2
packages/deliverables/workspace-changes/package.json

@@ -38,7 +38,6 @@
   "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:^"
@@ -48,7 +47,6 @@
     "@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:^",

+ 12 - 35
packages/deliverables/workspace-changes/src/git.ts

@@ -1,7 +1,5 @@
 /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
-import { createHash } from 'node:crypto'
-import { existsSync } from 'node:fs'
-import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
+import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
@@ -96,58 +94,37 @@ export interface GitWorkspace {
   root: string
   /** Absolute git directory holding the repository's index. */
   gitDir: string
-  /** Directory under the Harness home that receives every snapshot blob and tree. */
-  objectsDir: string
-  /** Environment that routes object writes to {@link objectsDir} and object reads through the repository's store. */
+  /** Environment that routes object writes to the private store and object reads through the repository's store. */
   env: Readonly<Record<string, string>>
 }
 
-/** Placement and bound of the private snapshot object stores. */
-export interface ObjectStoreOptions {
-  /** Directory that holds one object store per repository. */
-  home: string
-  /** Bytes one repository's store may hold; a larger store is discarded before the next snapshot. */
-  maxBytes: number
-}
-
 /** Whether a filesystem error names a missing path. */
 function isMissing(error: unknown): boolean {
   return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
 }
 
-/** Total size of the regular files under a directory; zero when it does not exist. */
-async function directoryBytes(directory: string): Promise<number> {
-  if (!existsSync(directory)) return 0
-  let total = 0
-  for (const entry of await readdir(directory, { recursive: true, withFileTypes: true })) {
-    if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size
-  }
-  return total
-}
-
 /**
- * Locate the repository enclosing a working directory and prepare its snapshot
- * object store. The repository's own object store is attached read-only as an
- * alternate, so snapshots read committed content from it and write nothing
- * into it; a store over its byte bound is discarded and starts empty. A
- * directory outside any repository yields null; any other git failure throws.
+ * Locate the repository enclosing a working directory and prepare the private
+ * object store its snapshots write to. The repository's own object store is
+ * attached read-only as an alternate, so snapshots read committed content from
+ * it and write nothing into it. A directory outside any repository yields
+ * null; any other git failure throws.
  * @param git - command runner.
  * @param cwd - absolute Session working directory.
- * @param store - snapshot object store placement and bound.
+ * @param objectsDir - yields the directory that receives every snapshot blob and tree; called only for a located repository.
  * @param signal - cancellation.
  * @returns the repository, or null when the directory is not inside one.
  */
 export async function locateGitWorkspace(
-  git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal,
+  git: GitRunner, cwd: string, objectsDir: () => Promise<string>, signal: AbortSignal,
 ): Promise<GitWorkspace | null> {
   const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects'], { cwd, signal })
   if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null
   const lines = ok(found, 'git rev-parse').stdout.split('\n').map(line => resolve(cwd, line))
   const [root, gitDir, repositoryObjects] = lines as [string, string, string]
-  const objectsDir = join(store.home, createHash('sha256').update(gitDir).digest('hex').slice(0, 16))
-  if (await directoryBytes(objectsDir) > store.maxBytes) await rm(objectsDir, { recursive: true, force: true })
-  await mkdir(objectsDir, { recursive: true })
-  return { root, gitDir, objectsDir, env: { GIT_OBJECT_DIRECTORY: objectsDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } }
+  const objects = await objectsDir()
+  await mkdir(objects, { recursive: true })
+  return { root, gitDir, env: { GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } }
 }
 
 /**

+ 29 - 29
packages/deliverables/workspace-changes/src/index.ts

@@ -1,22 +1,23 @@
 /**
- * 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.
- * Only a working directory inside a git repository is recorded.
+ * Summarizes the files each top-level turn changed from git working-tree
+ * snapshots taken at turn start and turn end plus the hunks file tools persist
+ * for paths git does not cover. Each summary is announced by a `workspace/changes`
+ * Session event that carries only the turn number and is served through the
+ * `workspaceChanges` service until the Session is disposed. Only a working
+ * directory inside a git repository is recorded.
  */
-import { homedir } from 'node:os'
-import { join } from 'node:path'
+import { homedir, tmpdir } from 'node:os'
 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 { Session, SessionId } 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 { TurnRecorder } from './recorder.ts'
+import type { WorkspaceChanges } from './types.ts'
 
-export type { WorkspaceChangedFile, WorkspaceChangesData } from './types.ts'
+export type { WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary } from './types.ts'
 
 /** Stable Loader identity. */
 export const name = 'workspace-changes'
@@ -24,24 +25,18 @@ export const name = 'workspace-changes'
 /** Services used to run git and observe turns. */
 export const inject = ['subprocess']
 
-/** Snapshot bounds and object store placement. Invalid values fail plugin load. */
+/** Snapshot bounds. Invalid values fail plugin load. */
 export interface Config {
-  /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */
-  dshHome?: string
-  /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */
-  objectStoreMaxBytes: number
   /** 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. */
+  /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
 }
 
 /** Schemastery validation for {@link Config}. */
 export const Config: z<Config> = z.object({
-  dshHome: z.string(),
-  objectStoreMaxBytes: z.number().default(1024 * 1024 * 1024),
   timeoutMs: z.number().default(30_000),
   outputMaxBytes: z.number().default(8 * 1024 * 1024),
   maxFiles: z.number().default(500),
@@ -78,25 +73,32 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise<string | n
 
 /**
  * Observe top-level turns of every Session whose working directory lies in a
- * git repository and append their change summaries.
+ * git repository, announce their change summaries, and serve them as
+ * `workspaceChanges`.
  * @param ctx - host context with `subprocess`.
- * @param config - validated bounds and placement.
+ * @param config - validated bounds.
  */
 export function apply(ctx: Context, config: Config): void {
   for (const [field, value] of [
     ['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles],
-    ['objectStoreMaxBytes', config.objectStoreMaxBytes],
   ] 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(() => () => {
+  const byId = new Map<SessionId, TurnRecorder>()
+  const forget = (session: Session): Promise<void> => {
+    const recorder = recorders.get(session)
+    recorders.delete(session)
+    byId.delete(session.id)
+    return recorder?.dispose() ?? Promise.resolve()
+  }
+  ctx.effect(() => async () => {
     lifetime.abort()
-    for (const recorder of recorders.values()) recorder.dispose()
-    recorders.clear()
+    await Promise.all([...recorders.keys()].map(forget))
   })
-  const objects = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), maxBytes: config.objectStoreMaxBytes }
+  const service: WorkspaceChanges = { summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq) }
+  ctx.provide('workspaceChanges', service)
   let runner: Promise<GitRunner | null> | undefined
   const gitRunner = (): Promise<GitRunner | null> => {
     runner ??= resolveGit(ctx, lifetime.signal).then((executable) => {
@@ -112,10 +114,11 @@ export function apply(ctx: Context, config: Config): void {
     let recorder = recorders.get(session)
     if (recorder === undefined) {
       recorder = new TurnRecorder(session, cwd, {
-        git: gitRunner(), objects, maxFiles: config.maxFiles,
+        git: gitRunner(), tempRoot: tmpdir(), maxFiles: config.maxFiles,
         warn: (message) => { ctx.logger.warn(message) },
       })
       recorders.set(session, recorder)
+      byId.set(session.id, recorder)
     }
     return recorder
   }
@@ -129,10 +132,7 @@ export function apply(ctx: Context, config: Config): void {
     else 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('session/disposed', (session) => { void forget(session) })
   ctx.on('agent/turn-stopping', async ({ agent, turn }) => {
     await recorders.get(agent.session)?.stopping(turn)
   })

+ 40 - 20
packages/deliverables/workspace-changes/src/recorder.ts

@@ -1,21 +1,21 @@
-/** Per-Session turn recorder: snapshot at turn start, diff and append at turn end. */
-import { realpath } from 'node:fs/promises'
+/** Per-Session turn recorder: snapshot at turn start, diff at turn end, summary kept until disposal. */
+import { mkdtemp, realpath, rm } from 'node:fs/promises'
 import { homedir } from 'node:os'
-import { relative, resolve } from 'node:path'
+import { join, relative, resolve } 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 ObjectStoreOptions } from './git.ts'
+import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts'
 import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts'
 import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts'
-import type { WorkspaceChangedFile } from './types.ts'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary } 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>
-  /** Where snapshot objects live and how large one repository's store may grow. */
-  objects: ObjectStoreOptions
-  /** Maximum files carried by one event. */
+  /** Directory that receives each Session's snapshot object directory. */
+  tempRoot: string
+  /** Maximum files carried by one summary. */
   maxFiles: number
   /** Failure reporter; a failed turn records nothing and the next turn retries. */
   warn: (message: string) => void
@@ -56,9 +56,11 @@ function freshState(turn: number): TurnState {
 
 /**
  * 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. A
- * working directory outside any repository records nothing.
+ * snapshot with its diff, and the appended `workspace/changes` event whose
+ * summary this recorder keeps. Snapshot objects live in a temporary directory
+ * owned by the recorder; disposal removes it together with the summaries.
+ * Tool execution waits for pending work so a snapshot never races a mutation.
+ * A working directory outside any repository records nothing.
  */
 export class TurnRecorder {
   private chain: Promise<void> = Promise.resolve()
@@ -66,6 +68,10 @@ export class TurnRecorder {
   private state = freshState(0)
   /** The located repository, reused across turns once found; null keeps retrying each turn. */
   private located: Located | null = null
+  /** Temporary directory holding this Session's snapshot objects, created with the first located repository. */
+  private objectsDir: string | undefined
+  /** Summaries by the sequence of the event that announced them. */
+  private readonly summaries = new Map<number, WorkspaceChangesSummary>()
   private readonly lifetime = new AbortController()
 
   constructor(
@@ -142,9 +148,24 @@ export class TurnRecorder {
     return this.chain
   }
 
-  /** Abort queued git work; the recorder accepts nothing afterwards. */
-  dispose(): void {
+  /**
+   * The summary announced by one `workspace/changes` event of this Session.
+   * @param seq - the event's sequence number.
+   * @returns the summary, or undefined for a sequence this recorder did not announce.
+   */
+  summary(seq: number): WorkspaceChangesSummary | undefined {
+    return this.summaries.get(seq)
+  }
+
+  /**
+   * Abort queued git work, forget every summary, and remove the snapshot objects.
+   * @returns once the temporary directory is gone.
+   */
+  async dispose(): Promise<void> {
     this.lifetime.abort()
+    this.summaries.clear()
+    await this.chain
+    if (this.objectsDir !== undefined) await rm(this.objectsDir, { recursive: true, force: true })
   }
 
   private enqueue(task: (signal: AbortSignal) => Promise<void>): Promise<void> {
@@ -171,7 +192,10 @@ export class TurnRecorder {
     const git = await this.env.git
     if (git === null) return null
     const cwd = await realpath(this.cwd)
-    const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal)
+    const workspace = await locateGitWorkspace(git, cwd, async () => {
+      this.objectsDir ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-objects-'))
+      return this.objectsDir
+    }, signal)
     if (workspace === null) return null
     this.located = { git, workspace, cwd, home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() }
     return this.located
@@ -206,12 +230,8 @@ export class TurnRecorder {
     const sorted = [...files.values()].sort(compareDisplay)
     // An empty list after an earlier in-turn record supersedes that record.
     if (sorted.length === 0 && state.recordedAfterSeq < 0) return
-    const event = this.session.append('workspace/changes', {
-      turn: state.turn,
-      files: sorted.slice(0, this.env.maxFiles),
-      total: sorted.length,
-      snapshot: { before, after },
-    })
+    const event = this.session.append('workspace/changes', { turn: state.turn })
+    this.summaries.set(event.seq, { turn: state.turn, cwd: this.cwd, files: sorted.slice(0, this.env.maxFiles), total: sorted.length })
     state.recordedAfterSeq = event.seq
   }
 }

+ 30 - 7
packages/deliverables/workspace-changes/src/types.ts

@@ -1,4 +1,5 @@
-/** Durable per-turn workspace change summaries and their Session event. */
+/** Per-turn workspace change summaries, the Session event that announces them, and the Host service that serves them. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 /** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
 export interface WorkspaceChangedFile {
@@ -18,21 +19,43 @@ export interface WorkspaceChangedFile {
   binary?: true
 }
 
-/** Files changed during one top-level turn. */
-export interface WorkspaceChangesData {
+/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */
+export interface WorkspaceChangesSummary {
   /** The turn whose file changes this summary describes. */
   turn: number
+  /** The Session working directory `path` values are relative to. */
+  cwd: string
   /** Changed files in `display` order, capped at the plugin's `maxFiles`. */
   files: WorkspaceChangedFile[]
   /** Complete changed-file count, including files omitted by the cap. */
   total: number
-  /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */
-  snapshot: { before: string; after: string }
+}
+
+/** Serves the summaries the recorder keeps for live Sessions. */
+export interface WorkspaceChanges {
+  /**
+   * The summary announced by one `workspace/changes` event.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
+   */
+  summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
 }
 
 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
+    /**
+     * A completed top-level turn's changed files were summarized; the summary itself stays on the
+     * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
+     * lives. The latest event for one turn replaces earlier ones.
+     */
+    'workspace/changes': { turn: number }
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Per-turn changed-file summaries of live Sessions. */
+    workspaceChanges: WorkspaceChanges
   }
 }

+ 36 - 14
packages/deliverables/workspace-changes/tests/git.spec.ts

@@ -1,5 +1,5 @@
 /** Git command bounds, snapshot recovery, and diff failure reporting. */
-import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
+import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
@@ -9,6 +9,9 @@ import { TurnRecorder } from '../src/recorder.ts'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import { git, scratchDir } from './support.ts'
 
+/** An object directory factory under a scratch root. */
+const objectsIn = (root: string) => () => Promise.resolve(join(root, 'objects'))
+
 const cleanups: Array<() => Promise<unknown>> = []
 afterEach(async () => {
   for (const cleanup of cleanups.reverse()) await cleanup()
@@ -55,7 +58,7 @@ describe('snapshots and diffs', () => {
     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: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, signal)
+    const workspace = await locateGitWorkspace(runnerGit, cwd, objectsIn(await scratchDir('dsh-git-store-', cleanups)), signal)
     expect(workspace?.root).toBe(await realpath(cwd))
     const tree = await snapshotTree(runnerGit, workspace!, signal)
     expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
@@ -65,9 +68,9 @@ describe('snapshots and diffs', () => {
   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 store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
     expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull()
-    const broken = { root: cwd, gitDir: join(cwd, 'missing'), objectsDir: join(cwd, 'missing-objects'), env: {} }
+    const broken = { root: cwd, gitDir: join(cwd, 'missing'), env: {} }
     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())
@@ -95,7 +98,7 @@ describe('repository edge cases', () => {
     await chmod(join(cwd, 'locked.txt'), 0o000)
     cleanups.push(() => chmod(join(cwd, 'locked.txt'), 0o644))
     const { git: runnerGit } = await runner()
-    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
     const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
     expect(await snapshotTree(runnerGit, workspace, signal)).toMatch(/^[0-9a-f]{40,64}$/)
   })
@@ -105,7 +108,7 @@ describe('repository edge cases', () => {
     git(cwd, 'init', '-q', '-b', 'main')
     await mkdir(join(cwd, '.git', 'index'))
     const { git: runnerGit } = await runner()
-    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
     const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
     await expect(snapshotTree(runnerGit, workspace, signal)).rejects.toThrow()
   })
@@ -117,12 +120,12 @@ describe('repository edge cases', () => {
     const original = await readFile(config, 'utf8')
     await writeFile(config, original.replace(/repositoryformatversion = \d+/, 'repositoryformatversion = 99'))
     const { git: runnerGit } = await runner()
-    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    const store = objectsIn(await scratchDir('dsh-git-store-', cleanups))
     await expect(locateGitWorkspace(runnerGit, cwd, store, signal)).rejects.toThrow('git rev-parse failed')
     await writeFile(config, original)
     const blocked = join(cwd, 'store-file')
     await writeFile(blocked, 'not a directory')
-    await expect(locateGitWorkspace(runnerGit, cwd, { home: blocked, maxBytes: 1 }, signal)).rejects.toThrow()
+    await expect(locateGitWorkspace(runnerGit, cwd, objectsIn(blocked), signal)).rejects.toThrow()
   })
 })
 
@@ -134,19 +137,18 @@ describe('TurnRecorder', () => {
     const warnings: string[] = []
     let release!: (runner: GitRunner | null) => void
     const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
-    const env = {
-      git: gate, objects: { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 },
-      maxFiles: 10, warn: (m: string) => { warnings.push(m) },
-    }
+    const tempRoot = await scratchDir('dsh-git-store-', cleanups)
+    const env = { git: gate, tempRoot, 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()
+    const disposal = disposed.dispose()
     release(runnerGit)
-    await disposed.settled()
+    await disposal
     disposed.start(2)
     await disposed.settled()
     expect(warnings).toEqual([])
+    expect(disposed.summary(1)).toBeUndefined()
 
     const { git: missing } = await runner(undefined, '/nonexistent/git-binary')
     const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) })
@@ -155,4 +157,24 @@ describe('TurnRecorder', () => {
     expect(warnings).toHaveLength(1)
     expect(warnings[0]).toContain('workspace-changes:')
   })
+
+  it('removes its snapshot objects on disposal and never creates them outside a repository', async () => {
+    const tempRoot = await scratchDir('dsh-git-store-', cleanups)
+    const { ctx, git: runnerGit } = await runner()
+    const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } }
+    const plain = new TurnRecorder(ctx.sessions.create(SessionId('plain'), { meta: { cwd: tempRoot } }), tempRoot, env)
+    plain.start(1)
+    await plain.settled()
+    await plain.dispose()
+    const cwd = await scratchDir('dsh-recorder-repo-', cleanups)
+    git(cwd, 'init', '-q', '-b', 'main')
+    const repo = new TurnRecorder(ctx.sessions.create(SessionId('repo'), { meta: { cwd } }), cwd, env)
+    repo.start(1)
+    await repo.settled()
+    const [objects, ...others] = (await readdir(tempRoot)).filter(entry => entry.startsWith('dsh-workspace-changes-objects-'))
+    expect(others).toEqual([])
+    expect(objects).toBeDefined()
+    await repo.dispose()
+    expect(await readdir(tempRoot)).toEqual([])
+  })
 })

+ 2 - 6
packages/deliverables/workspace-changes/tests/loader-composition.spec.ts

@@ -34,8 +34,6 @@ describe('real Loader composition', () => {
       "- 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()
@@ -73,10 +71,8 @@ describe('real Loader composition', () => {
     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)
+    const [recorded, ...rest] = changes(context, 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]+$/)
+    expect(recorded).toEqual({ turn: 1, cwd, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] })
   })
 })

+ 31 - 59
packages/deliverables/workspace-changes/tests/plugin.spec.ts

@@ -1,5 +1,5 @@
 /** The plugin records each top-level turn's file changes from real git snapshots. */
-import { mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
+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'
@@ -22,9 +22,8 @@ async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
   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 }
+  const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config)
+  return { ctx, fiber }
 }
 
 /** Loose objects the repository itself holds. */
@@ -49,7 +48,7 @@ describe('workspace-changes in a repository', () => {
     const cwd = await repository()
     await writeFile(join(cwd, 'b.txt'), 'x user\n')
     await writeFile(join(cwd, 'u.txt'), 'user untracked\n')
-    const { ctx, dshHome } = await boot()
+    const { ctx } = await boot()
     const repositoryObjects = looseObjects(cwd)
     const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } })
     startTurn(session, 1)
@@ -79,11 +78,11 @@ describe('workspace-changes in a repository', () => {
     endTurn(session, 1)
     await settle(ctx, session)
 
-    const [recorded, ...rest] = changes(session)
+    const events = session.snapshotEvents().filter(event => event.type === 'workspace/changes')
+    expect(events.map(event => event.data)).toEqual([{ turn: 1 }])
+    const [recorded, ...rest] = changes(ctx, 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).toMatchObject({ turn: 1, cwd, total: 6 })
     expect(recorded!.files).toEqual([
       { path: '.env', display: '.env', added: 2, deleted: 0 },
       { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 },
@@ -95,42 +94,15 @@ describe('workspace-changes in a repository', () => {
     expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([
       ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt',
     ])
-    // Snapshot objects live under the Harness home; the repository's own store is untouched.
+    // Snapshot objects live in the recorder's temporary directory; the repository's own store is untouched.
     expect(looseObjects(cwd)).toBe(repositoryObjects)
-    const stores = await readdir(join(dshHome, 'workspace-changes'))
-    expect(stores).toHaveLength(1)
-    const objects = await readdir(join(dshHome, 'workspace-changes', stores[0]!), { recursive: true })
-    expect(objects.some(entry => /^[0-9a-f]{2}\/[0-9a-f]{38,}$/.test(entry.replaceAll('\\', '/')))).toBe(true)
-  })
-
-  it('discards a snapshot object store that outgrew its bound when the next Session locates the repository', async () => {
-    const cwd = await repository()
-    const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 })
-    const first = ctx.sessions.create(SessionId('bounded-1'), { meta: { cwd } })
-    startTurn(first, 1)
-    await settle(ctx, first)
-    const [store] = await readdir(join(dshHome, 'workspace-changes'))
-    const marker = join(dshHome, 'workspace-changes', store!, 'marker')
-    await writeFile(marker, 'old store')
-    await writeFile(join(cwd, 'n.txt'), 'n\n')
-    toolCall(first, 1, 'bash', { command: 'x' })
-    endTurn(first, 1)
-    await settle(ctx, first)
-    expect(changes(first)).toHaveLength(1)
-    // The located repository is reused within a Session, so the bound is checked once per Session.
-    startTurn(first, 2)
-    await settle(ctx, first)
-    expect((await stat(marker)).isFile()).toBe(true)
-    endTurn(first, 2)
-    const second = ctx.sessions.create(SessionId('bounded-2'), { meta: { cwd } })
-    startTurn(second, 1)
-    await settle(ctx, second)
-    await expect(stat(marker)).rejects.toThrow()
-    await writeFile(join(cwd, 'm.txt'), 'm\n')
-    toolCall(second, 1, 'bash', { command: 'x' })
-    endTurn(second, 1)
-    await settle(ctx, second)
-    expect(changes(second).at(-1)!.files.map(file => file.display)).toEqual(['m.txt'])
+    // Summaries are served by session and event sequence only while the Session lives.
+    const seq = events[0]!.seq
+    expect(ctx.workspaceChanges.summary(session.id, seq)).toBe(recorded)
+    expect(ctx.workspaceChanges.summary(session.id, seq + 1)).toBeUndefined()
+    expect(ctx.workspaceChanges.summary(SessionId('elsewhere'), seq)).toBeUndefined()
+    ctx.emit('session/disposed', session)
+    expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined()
   })
 
   it('places files above the working directory and outside the repository by their display rule', async () => {
@@ -148,7 +120,7 @@ describe('workspace-changes in a repository', () => {
     toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' })
     endTurn(session, 1, 'blocked')
     await settle(ctx, session)
-    const [recorded] = changes(session)
+    const [recorded] = changes(ctx, 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 },
@@ -164,16 +136,16 @@ describe('workspace-changes in a repository', () => {
     const signal = new AbortController().signal
     startTurn(session, 1)
     await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
-    expect(changes(session)).toEqual([])
+    expect(changes(ctx, 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 })
+    expect(ctx.workspaceChanges.summary(session.id, inTurn!.seq)).toMatchObject({ turn: 1, total: 1 })
     endTurn(session, 1)
     await settle(ctx, session)
-    expect(changes(session)).toHaveLength(1)
+    expect(changes(ctx, session)).toHaveLength(1)
     expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq)
 
     startTurn(session, 2)
@@ -185,7 +157,7 @@ describe('workspace-changes in a repository', () => {
     toolCall(session, 2, 'bash', { command: 'steered' })
     endTurn(session, 2)
     await settle(ctx, session)
-    const second = changes(session).filter(data => data.turn === 2)
+    const second = changes(ctx, 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 })
 
@@ -194,7 +166,7 @@ describe('workspace-changes in a repository', () => {
     toolCall(session, 3, 'read', { file_path: 'a.txt' })
     endTurn(session, 3)
     await settle(ctx, session)
-    expect(changes(session).filter(data => data.turn === 3)).toEqual([])
+    expect(changes(ctx, session).filter(data => data.turn === 3)).toEqual([])
   })
 
   it('keeps an interrupted turn’s record when the next turn starts before it settles', async () => {
@@ -212,7 +184,7 @@ describe('workspace-changes in a repository', () => {
     toolCall(session, 2, 'bash', { command: 'x' })
     endTurn(session, 2)
     await settle(ctx, session)
-    expect(changes(session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]])
+    expect(changes(ctx, session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]])
   })
 
   it('caps the file list while reporting the complete count', async () => {
@@ -226,7 +198,7 @@ describe('workspace-changes in a repository', () => {
     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)
+    const [recorded] = changes(ctx, session)
     expect(recorded!.total).toBe(3)
     expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
   })
@@ -243,7 +215,7 @@ describe('workspace-changes in a repository', () => {
     toolCall(session, 1, 'bash', { command: 'x' })
     endTurn(session, 1)
     await settle(ctx, session)
-    expect(changes(session)).toEqual([])
+    expect(changes(ctx, 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('missing-git')
@@ -273,7 +245,7 @@ describe('workspace-changes without a repository', () => {
     })
     endTurn(session, 1)
     await settle(ctx, session)
-    expect(changes(session)).toEqual([])
+    expect(changes(ctx, session)).toEqual([])
     expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
     await expect(stat(join(cwd, '.git'))).rejects.toThrow()
   })
@@ -289,14 +261,14 @@ describe('workspace-changes without a repository', () => {
     toolCall(session, 1, 'bash', { command: 'x' })
     endTurn(session, 1)
     await settle(ctx, session)
-    expect(changes(session)).toEqual([])
+    expect(changes(ctx, 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']])
+    expect(changes(ctx, session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']])
 
     startTurn(session, 3)
     await settle(ctx, session)
@@ -305,7 +277,7 @@ describe('workspace-changes without a repository', () => {
     toolCall(session, 3, 'bash', { command: 'x' })
     endTurn(session, 3)
     await settle(ctx, session)
-    expect(changes(session).filter(data => data.turn === 3)).toEqual([])
+    expect(session.snapshotEvents().filter(event => event.type === 'workspace/changes' && event.data.turn === 3)).toEqual([])
   })
 
   it('ignores subagent sessions and sessions without a working directory', async () => {
@@ -322,7 +294,7 @@ describe('workspace-changes without a repository', () => {
       toolCall(session, 1, 'bash', { command: 'x' })
       endTurn(session, 1)
       await settle(ctx, session)
-      expect(changes(session)).toEqual([])
+      expect(changes(ctx, session)).toEqual([])
     }
     await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
   })
@@ -343,7 +315,7 @@ describe('workspace-changes without git', () => {
       endTurn(session, turn)
       await settle(ctx, session)
     }
-    expect(changes(session)).toEqual([])
+    expect(changes(ctx, session)).toEqual([])
     expect(info).toHaveBeenCalledTimes(1)
     expect(info.mock.calls[0]![0]).toContain('git is unavailable')
   })

+ 7 - 4
packages/deliverables/workspace-changes/tests/support.ts

@@ -7,7 +7,7 @@ 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'
+import type { WorkspaceChangesSummary } from '../src/types.ts'
 
 let callNumber = 0
 
@@ -61,7 +61,10 @@ 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)
+/** The summaries the Host still serves for one session's `workspace/changes` events, in log order. */
+export function changes(ctx: Context, session: Session): WorkspaceChangesSummary[] {
+  return session.snapshotEvents()
+    .filter(event => event.type === 'workspace/changes')
+    .map(event => ctx.workspaceChanges.summary(session.id, event.seq))
+    .filter(summary => summary !== undefined)
 }

+ 21 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2999,6 +2999,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'workspaceChanges',
+    summary: 'Serves the summaries the recorder keeps for live Sessions.',
+    description: 'Serves the summaries the recorder keeps for live Sessions.',
+    methods: [
+      {
+        signature: 'summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined',
+        description: 'The summary announced by one `workspace/changes` event.',
+        parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }],
+        returns: 'the summary, or undefined once its Session was disposed or when this Host never recorded it.',
+      },
+    ],
+  },
   {
     key: 'workspaceController',
     summary: 'Host service backing the generated `ctx.remote.workspace` namespace.',
@@ -6646,6 +6659,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WorkspaceByteRange',
     declaration: 'export interface WorkspaceByteRange {\n    readonly offset?: number;\n    readonly length?: number;\n}',
   },
+  {
+    name: 'WorkspaceChangedFile',
+    declaration: 'export interface WorkspaceChangedFile {\n    path: string;\n    display: string;\n    added: number;\n    deleted: number;\n    binary?: true;\n}',
+  },
+  {
+    name: 'WorkspaceChangesSummary',
+    declaration: 'export interface WorkspaceChangesSummary {\n    turn: number;\n    cwd: string;\n    files: WorkspaceChangedFile[];\n    total: number;\n}',
+  },
   {
     name: 'WorkspaceCreateRequest',
     declaration: 'export interface WorkspaceCreateRequest {\n    readonly path: string;\n}',

+ 0 - 3
pnpm-lock.yaml

@@ -5674,9 +5674,6 @@ importers:
       '@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

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -128,6 +128,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   workspaceRegistry: 'workspace.md',
   workspaceController: 'workspace.md',
   workspaceFiles: 'workspace.md',
+  workspaceChanges: 'deliverables.md',
   terminalController: 'workspace.md',
   directoryPickerController: 'workspace.md',
 }
@@ -252,6 +253,7 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
  * appear on more than one page.
  */
 export const LINK_MAP: Readonly<Record<string, string>> = {
+  WorkspaceChangesSummary: 'deliverables.md',
   BrowserUseProviderName: 'browser-use.md',
   ComputerUseProviderName: 'computer-use.md',
   Agent: 'core.md',

+ 7 - 0
scripts/gen-doc-graphs.ts

@@ -226,6 +226,13 @@ const SERVICE_ROLES: ServiceRole[] = [
     mode: 'core',
     note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.',
   },
+  {
+    key: 'workspaceChanges',
+    pkg: 'workspace-changes',
+    title: 'Host per-turn changed-file summaries',
+    mode: 'core',
+    note: 'Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn.',
+  },
   {
     key: 'terminalController',
     pkg: 'api-terminal-controller',

+ 1 - 1
scripts/gen-persistence-catalog.ts

@@ -40,8 +40,8 @@ const LINK_MAP: Record<string, string> = {
   StreamChunk: 'subsystems/llm-streaming.md',
   TokenUsage: 'subsystems/llm-streaming.md',
   TodoItem: 'subsystems/todo.md',
+  WorkspaceChangesSummary: 'subsystems/deliverables.md',
   PresentedFile: 'subsystems/deliverables.md',
-  WorkspaceChangesData: 'subsystems/deliverables.md',
   TurnTrigger: 'subsystems/session.md',
   TurnEndReason: 'subsystems/session.md',
   SessionTitleEventData: 'subsystems/session-title.md',

+ 6 - 1
scripts/type-equiv.manifest.json

@@ -2174,7 +2174,12 @@
     },
     {
       "doc": "docs/subsystems/deliverables.md",
-      "symbol": "WorkspaceChangesData",
+      "symbol": "WorkspaceChangesSummary",
+      "source": "packages/deliverables/workspace-changes/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/deliverables.md",
+      "symbol": "WorkspaceChanges",
       "source": "packages/deliverables/workspace-changes/src/types.ts"
     }
   ]

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
snapshots/web/changed-files-turn/session.v3.jsonl


+ 0 - 1
tsconfig.host.json

@@ -92,7 +92,6 @@
     "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/changed-files.e2e.ts",
     "apps/web/tests/changed-files-turn.e2e.ts",
     "apps/web/tests/produced-file-mentions.e2e.ts",
     "apps/web/tests/subagent-conversation.e2e.ts",

Неке датотеке нису приказане због велике количине промена