Преглед на файлове

feat(workspace-changes): keep snapshot objects in a private store under the Harness home

Write every snapshot blob and tree into a per-repository object directory
under DSH_HOME/workspace-changes with the repository's objects attached as a
read-only alternate, so the user's repository gains no objects and the Harness
owns retention through objectStoreMaxBytes. The placement follows the
workspace change journal POC and costs nothing measurable.
creatixchu преди 1 месец
родител
ревизия
d6634272d5

+ 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: c477935fad1fa95ae0d60a67f31a89c38b020087
-2026-09-11-turn-changed-files-card.zh.md: 23ee760f18d6f9a6e9b540ef0e9bde452968a308
+2026-09-11-turn-changed-files-card.md: a14e062d7801de089a7644ae335d246a206fa655
+2026-09-11-turn-changed-files-card.zh.md: 0f69397b58543eeee79e5f5d57eb08aa31618494

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

@@ -12,7 +12,7 @@ After a turn, users want to see which files the model changed and by how much. T
 
 The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible.
 
-The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it.
+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.
 
 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.
 
@@ -40,7 +40,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 repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff.
+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.
 
 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.
 

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

@@ -12,7 +12,7 @@ Status: implemented
 
 Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。
 
-记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。
+记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 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 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。
 
@@ -40,7 +40,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 ## Consequences
 
-每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。
+每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入私有对象库;超过配置上限的对象库会被丢弃,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。
 
 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
 

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

@@ -609,6 +609,8 @@ 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

+ 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: e776a396ca0c18b02d9e91f0e979e102cf40aba4
-config-catalog.zh.md: 2eb933afd86b7c3358bb63daa7639b050fb68d1d
+config-catalog.md: 4cc75e2bf73887a2dca9ee9c2314d70626d1a66a
+config-catalog.zh.md: df436e5b67c637b0a99416a9a8250aef95da7f50

+ 6 - 2
docs/config-catalog.md

@@ -3454,8 +3454,12 @@ Source: [`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages
 Requires: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot bounds and object store placement. 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. */
@@ -3465,7 +3469,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.ts)
+Source: [`packages/fs/workspace-changes/src/index.ts:30`](../packages/fs/workspace-changes/src/index.ts)
 
 ## Loadable plugins with no config
 

+ 6 - 2
docs/config-catalog.zh.md

@@ -3456,8 +3456,12 @@ export interface Config {
 依赖: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot bounds and object store placement. 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. */
@@ -3467,7 +3471,7 @@ export interface Config {
 }
 ```
 
-来源: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.ts)
+来源: [`packages/fs/workspace-changes/src/index.ts:30`](../packages/fs/workspace-changes/src/index.ts)
 
 ## 无配置的可加载插件
 

+ 2 - 2
packages/fs/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/fs/workspace-changes/README.md
-README.md: 0c25dbacb039c32cc7cb7f3cb3ec57e251cc3de3
-README.zh.md: 1e400e422c87f9b9cd86c532ffdf1143daf92717
+README.md: a6eff33162095afea9064af69f8685d946c3eab3
+README.zh.md: f8ac055711e0c888c3d5b79d2ce3c28d4fc90f32

+ 5 - 3
packages/fs/workspace-changes/README.md

@@ -35,11 +35,13 @@ 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 |
 
-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 go into the repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. 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 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. A store that outgrows `objectStoreMaxBytes` is discarded before the next snapshot and 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.
 
 Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded.
 
@@ -53,7 +55,7 @@ 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, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. 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 and its object store, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. 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/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. 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.
 
@@ -83,7 +85,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not.
+- 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.
 - Edits the user makes during a turn are attributed to that turn.
 - A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably.
 - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only.

+ 5 - 3
packages/fs/workspace-changes/README.zh.md

@@ -35,11 +35,13 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
+| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录为每个仓库存放一个快照对象库 |
+| `objectStoreMaxBytes` | `1073741824` | 单个仓库的快照对象库允许的字节数,超过后丢弃并从空开始 |
 | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 |
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
 | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 |
 
-工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
+工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。对象库超过 `objectStoreMaxBytes` 时在下次快照前被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。
 
 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
@@ -53,7 +55,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`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 释放与插件释放会中止排队的工作。
 
@@ -83,7 +85,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。
+- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。
 - 用户在轮次进行中自己做的编辑会被算到该轮。
 - 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。

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

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

+ 52 - 13
packages/fs/workspace-changes/src/git.ts

@@ -1,5 +1,6 @@
 /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
-import { copyFile, mkdtemp, rm } from 'node:fs/promises'
+import { createHash } from 'node:crypto'
+import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
@@ -88,33 +89,67 @@ function ok(result: GitRunResult, what: string): GitRunResult {
   return result
 }
 
-/** The repository enclosing a Session working directory. */
+/** The repository enclosing a Session working directory and the private store its snapshots write to. */
 export interface GitWorkspace {
   /** Repository top-level directory, the root every diff path is relative to. */
   root: string
-  /** Absolute git directory holding the index and objects. */
+  /** 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. */
+  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
+}
+
+/** Total size of the regular files under a directory; zero when it does not exist. */
+async function directoryBytes(directory: string): Promise<number> {
+  let total = 0
+  const entries = await readdir(directory, { recursive: true, withFileTypes: true }).catch(() => [])
+  for (const entry of entries) {
+    if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size
+  }
+  return total
 }
 
 /**
- * Locate the repository enclosing a working directory.
+ * 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.
  * @param git - command runner.
  * @param cwd - absolute Session working directory.
+ * @param store - snapshot object store placement and bound.
  * @param signal - cancellation.
  * @returns the repository, or null when the directory is not inside one.
  */
-export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise<GitWorkspace | null> {
-  const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal })
+export async function locateGitWorkspace(
+  git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal,
+): Promise<GitWorkspace | null> {
+  const found = await git.run([
+    'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects',
+  ], { cwd, signal })
   if (found.exitCode !== 0) return null
-  const [root, gitDir] = found.stdout.split('\n') as [string, string]
-  return { root, gitDir }
+  const [root, gitDir, repositoryObjects] = found.stdout.split('\n') 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 } }
 }
 
 /**
  * Write the complete work tree, including untracked and modified files but
  * not ignored ones, as a tree object through a private index seeded from the
- * repository's index. That index, the work tree, and every ref stay unchanged;
- * an in-progress merge keeps its unmerged entries.
+ * repository's index. New blobs and the tree land in the private object store;
+ * the repository's index, object store, work tree, and refs stay unchanged,
+ * and an in-progress merge keeps its unmerged entries.
  * @param git - command runner.
  * @param workspace - addressed repository.
  * @param signal - cancellation.
@@ -126,7 +161,7 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
     const index = join(scratch, 'index')
     // A repository without an index yet (fresh `git init`) starts from scratch.
     await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined)
-    const env = { GIT_INDEX_FILE: index }
+    const env = { ...workspace.env, GIT_INDEX_FILE: index }
     ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
     return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
   } finally {
@@ -148,7 +183,9 @@ export async function diffTrees(
   git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal,
 ): Promise<NumstatEntry[]> {
   if (before === after) return []
-  const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree')
+  const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
+    cwd: workspace.root, env: workspace.env, signal,
+  }), 'git diff-tree')
   if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap')
   return parseNumstat(result.stdout)
 }
@@ -167,7 +204,9 @@ export async function ignoredPaths(
   git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal,
 ): Promise<Set<string>> {
   if (paths.length === 0) return new Set()
-  const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal })
+  const result = await git.run(['check-ignore', '-z', '--stdin'], {
+    cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
+  })
   if (result.exitCode === 1) return new Set()
   return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''))
 }

+ 15 - 3
packages/fs/workspace-changes/src/index.ts

@@ -6,8 +6,10 @@
  */
 import { realpathSync } from 'node:fs'
 import { homedir } from 'node:os'
+import { join } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
+import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import type {} from '@deepseek-ai/dsh-agent'
 import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-subprocess'
@@ -24,8 +26,12 @@ export const name = 'workspace-changes'
 /** Services used to run git and observe turns. */
 export const inject = ['subprocess']
 
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot bounds and object store placement. 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. */
@@ -36,6 +42,8 @@ export interface Config {
 
 /** 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),
@@ -77,7 +85,10 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise<string | n
  * @param config - validated bounds and placement.
  */
 export function apply(ctx: Context, config: Config): void {
-  for (const [field, value] of [['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles]] as const) {
+  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()
@@ -89,6 +100,7 @@ export function apply(ctx: Context, config: Config): void {
   })
   const roots = temporaryRoots()
   const home = realpathSync.native(homedir())
+  const objects = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), maxBytes: config.objectStoreMaxBytes }
   let runner: Promise<GitRunner | null> | undefined
   const gitRunner = (): Promise<GitRunner | null> => {
     runner ??= resolveGit(ctx, lifetime.signal).then((executable) => {
@@ -104,7 +116,7 @@ export function apply(ctx: Context, config: Config): void {
     let recorder = recorders.get(session)
     if (recorder === undefined) {
       recorder = new TurnRecorder(session, cwd, {
-        git: gitRunner(), home, temporaryRoots: roots, maxFiles: config.maxFiles,
+        git: gitRunner(), objects, home, temporaryRoots: roots, maxFiles: config.maxFiles,
         warn: (message) => { ctx.logger.warn(message) },
       })
       recorders.set(session, recorder)

+ 4 - 2
packages/fs/workspace-changes/src/recorder.ts

@@ -3,7 +3,7 @@ import { realpath } from 'node:fs/promises'
 import { relative } from 'node:path'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import type { FileDiff } from '@deepseek-ai/dsh-tools'
-import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts'
+import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts'
 import { fileDiffsOf, hunkLineCounts } from './numstat.ts'
 import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts'
 import type { WorkspaceChangedFile } from './types.ts'
@@ -12,6 +12,8 @@ import type { WorkspaceChangedFile } from './types.ts'
 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
   /** Canonical absolute home directory abbreviated as `~` in display paths. */
   home: string
   /** Temporary roots whose files never enter a summary. */
@@ -68,7 +70,7 @@ export class TurnRecorder {
       if (git === null) return
       // git reports symlink-resolved paths; every comparison uses that form.
       const cwd = await realpath(this.cwd)
-      const workspace = await locateGitWorkspace(git, cwd, signal)
+      const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal)
       if (workspace === null) return
       const tree = await snapshotTree(git, workspace, signal)
       this.baseline = { git, workspace, tree, cwd }

+ 9 - 5
packages/fs/workspace-changes/tests/git.spec.ts

@@ -56,7 +56,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, signal)
+    const workspace = await locateGitWorkspace(runnerGit, cwd, { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, signal)
     expect(workspace?.root).toBe(await realpath(cwd))
     const tree = await snapshotTree(runnerGit, workspace!, signal)
     expect(tree).toMatch(/^[0-9a-f]{40,64}$/)
@@ -66,13 +66,14 @@ 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()
-    expect(await locateGitWorkspace(runnerGit, cwd, signal)).toBeNull()
-    const broken = { root: cwd, gitDir: join(cwd, 'missing') }
+    const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }
+    expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull()
+    const broken = { root: cwd, gitDir: join(cwd, 'missing'), objectsDir: join(cwd, 'missing-objects'), 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())
     git(cwd, 'init', '-q')
-    const workspace = (await locateGitWorkspace(runnerGit, cwd, signal))!
+    const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))!
     await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed')
     await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n'))
     const before = await snapshotTree(runnerGit, workspace, signal)
@@ -94,7 +95,10 @@ describe('TurnRecorder', () => {
     const warnings: string[] = []
     let release!: (runner: GitRunner | null) => void
     const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
-    const env = { git: gate, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
+    const env = {
+      git: gate, objects: { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 },
+      home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) },
+    }
     const disposed = new TurnRecorder(session, cwd, env)
     disposed.start(1)
     await new Promise(resolve => setTimeout(resolve, 5))

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

@@ -34,6 +34,8 @@ 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()

+ 41 - 4
packages/fs/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, realpath, rm, stat, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readdir, 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,8 +22,14 @@ async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
   cleanups.push(() => ctx.fiber.dispose())
   await ctx.plugin(SessionStore)
   await ctx.plugin(LocalSubprocessRuntime)
-  const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config)
-  return { ctx, fiber }
+  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 }
+}
+
+/** Loose objects the repository itself holds. */
+function looseObjects(cwd: string): number {
+  return Number(git(cwd, 'count-objects').split(' ')[0])
 }
 
 async function repository(): Promise<string> {
@@ -43,7 +49,8 @@ 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 } = await boot()
+    const { ctx, dshHome } = await boot()
+    const repositoryObjects = looseObjects(cwd)
     const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } })
     startTurn(session, 1)
     await settle(ctx, session)
@@ -84,6 +91,36 @@ 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.
+    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))).toBe(true)
+  })
+
+  it('discards a snapshot object store that outgrew its bound before the next baseline', async () => {
+    const cwd = await repository()
+    const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 })
+    const session = ctx.sessions.create(SessionId('bounded'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    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(session, 1, 'bash', { command: 'x' })
+    endTurn(session, 1)
+    await settle(ctx, session)
+    expect(changes(session)).toHaveLength(1)
+    startTurn(session, 2)
+    await settle(ctx, session)
+    await expect(stat(marker)).rejects.toThrow()
+    await writeFile(join(cwd, 'm.txt'), 'm\n')
+    toolCall(session, 2, 'bash', { command: 'x' })
+    endTurn(session, 2)
+    await settle(ctx, session)
+    expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['m.txt'])
   })
 
   it('places files above the working directory and outside the repository by their display rule', async () => {

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

@@ -25,6 +25,9 @@
     },
     {
       "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../util/home-paths"
     }
   ]
 }

+ 3 - 0
pnpm-lock.yaml

@@ -6288,6 +6288,9 @@ 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