Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2672

# Conflicts:
#	docs/module-graph.i18n.yaml
_Kerman 1 ay önce
ebeveyn
işleme
4b1683c287
100 değiştirilmiş dosya ile 2267 ekleme ve 696 silme
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml
  2. 31 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
  3. 31 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml
  5. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
  6. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml
  8. 3 2
      .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md
  9. 3 2
      .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml
  11. 0 2
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
  12. 0 2
      .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml
  14. 21 8
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
  15. 21 8
      .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md
  16. 2 2
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
  17. 1 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
  18. 1 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
  19. 12 0
      apps/cli/composition.md
  20. 1 1
      apps/cli/tests/built-bin.e2e.ts
  21. 21 0
      apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts
  22. 4 4
      apps/web/tests/agent-preset-selection.e2e.ts
  23. 65 10
      apps/web/tests/minimal-preset.snapshot.ts
  24. 29 11
      apps/web/tests/subagent-conversation.e2e.ts
  25. 2 2
      docs/config-catalog.i18n.yaml
  26. 8 7
      docs/config-catalog.md
  27. 8 7
      docs/config-catalog.zh.md
  28. 2 2
      docs/event-producer-consumer.i18n.yaml
  29. 1 1
      docs/event-producer-consumer.md
  30. 1 1
      docs/event-producer-consumer.zh.md
  31. 2 2
      docs/module-graph.i18n.yaml
  32. 1 2
      docs/module-graph.md
  33. 1 2
      docs/module-graph.zh.md
  34. 2 2
      docs/subsystems/session-projection.i18n.yaml
  35. 21 20
      docs/subsystems/session-projection.md
  36. 21 20
      docs/subsystems/session-projection.zh.md
  37. 2 2
      docs/subsystems/storage.i18n.yaml
  38. 8 0
      docs/subsystems/storage.md
  39. 8 0
      docs/subsystems/storage.zh.md
  40. 1 0
      knip.json
  41. 2 2
      packages/bundle/acp-app/README.i18n.yaml
  42. 1 1
      packages/bundle/acp-app/README.md
  43. 1 1
      packages/bundle/acp-app/README.zh.md
  44. 27 0
      packages/bundle/base/cordis.patch.yml
  45. 4 0
      packages/bundle/base/package.json
  46. 2 2
      packages/bundle/headless/README.i18n.yaml
  47. 1 1
      packages/bundle/headless/README.md
  48. 1 1
      packages/bundle/headless/README.zh.md
  49. 2 2
      packages/bundle/sdk-app/README.i18n.yaml
  50. 1 1
      packages/bundle/sdk-app/README.md
  51. 1 1
      packages/bundle/sdk-app/README.zh.md
  52. 2 2
      packages/bundle/web-app/README.i18n.yaml
  53. 0 0
      packages/bundle/web-app/README.md
  54. 0 0
      packages/bundle/web-app/README.zh.md
  55. 0 19
      packages/bundle/web-app/cordis.patch.yml
  56. 0 4
      packages/bundle/web-app/package.json
  57. 2 2
      packages/client/ui-tool/README.i18n.yaml
  58. 1 1
      packages/client/ui-tool/README.md
  59. 1 1
      packages/client/ui-tool/README.zh.md
  60. 16 1
      packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
  61. 12 8
      packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
  62. 14 1
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  63. 3 3
      packages/credentials/credentials-local/tests/migration.spec.ts
  64. 9 9
      packages/extensions/tool-cordis/src/api-catalog.ts
  65. 2 2
      packages/sdk/client/README.i18n.yaml
  66. 1 1
      packages/sdk/client/README.md
  67. 1 1
      packages/sdk/client/README.zh.md
  68. 52 7
      packages/sdk/client/src/api.ts
  69. 53 3
      packages/sdk/client/tests/fake-runtime.ts
  70. 55 1
      packages/sdk/client/tests/sdk-client.spec.ts
  71. 2 2
      packages/session/session-projection-cache/README.i18n.yaml
  72. 18 17
      packages/session/session-projection-cache/README.md
  73. 18 17
      packages/session/session-projection-cache/README.zh.md
  74. 5 6
      packages/session/session-projection-cache/package.json
  75. 65 77
      packages/session/session-projection-cache/src/index.ts
  76. 2 2
      packages/session/session-projection-cache/src/invariant.ts
  77. 17 13
      packages/session/session-projection-cache/src/spec.ts
  78. 256 230
      packages/session/session-projection-cache/tests/cache.spec.ts
  79. 7 7
      packages/session/session-projection-cache/tsconfig.json
  80. 4 1
      packages/shell/tool-pwsh/tests/loader.spec.ts
  81. 17 0
      packages/storage/storage-domain/src/spec.ts
  82. 12 1
      packages/storage/storage-domain/tests/domain.spec.ts
  83. 2 2
      packages/storage/storage-json/README.i18n.yaml
  84. 9 3
      packages/storage/storage-json/README.md
  85. 9 3
      packages/storage/storage-json/README.zh.md
  86. 40 1
      packages/storage/storage-json/src/format.ts
  87. 13 8
      packages/storage/storage-json/src/index.ts
  88. 275 0
      packages/storage/storage-json/src/per-record-unit.ts
  89. 16 9
      packages/storage/storage-json/src/single-unit.ts
  90. 194 1
      packages/storage/storage-json/tests/json-backend.spec.ts
  91. 12 1
      packages/storage/storage/src/backend.ts
  92. 2 2
      packages/subagent/subagent-dsh-sdk/README.i18n.yaml
  93. 27 4
      packages/subagent/subagent-dsh-sdk/README.md
  94. 27 4
      packages/subagent/subagent-dsh-sdk/README.zh.md
  95. 13 1
      packages/subagent/subagent-dsh-sdk/src/index.ts
  96. 173 33
      packages/subagent/subagent-dsh-sdk/src/run.ts
  97. 30 9
      packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child-mock-llm.ts
  98. 1 0
      packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml
  99. 64 11
      packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts
  100. 319 19
      packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
+2026-08-19-projection-cache-per-session-files.md: 9e102e786a6c06d82d1a0f45cc2f96a50c8abcd8
+2026-08-19-projection-cache-per-session-files.zh.md: d875c3f57800936f66fbf65233637df9bf300e2d

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md

@@ -0,0 +1,31 @@
+# Agent Note: Projection cache as per-session files
+
+Status: implemented
+
+English | [中文](2026-08-19-projection-cache-per-session-files.zh.md)
+
+## Problem
+
+The persisted projection cache was one global `session_projcache.json` — a `sessions` table in a single file at the storage root. Every throttled checkpoint rewrote the whole file containing every session's rows, so write amplification grew with session count, and one malformed file took the entire cache down at once.
+
+## Decision
+
+The cache opens the `session_projcache` storage domain in the new `per-record` layout, added to the json backend: one version-stamped document per session at `<root>/session_projcache/sessions/<id>.json`, owned by the storage stack — `storage` / `storage-json` / `storage-domain` live in the shared base bundle alongside the cache, and the cache itself is a plain domain consumer again. Every shipped base-backed profile keeps the cache enabled, so the session producer records checkpoints independently of whether its current application exposes a listing interface; `sdk-minimal`, which does not use the base bundle, remains outside this composition. The cache never consults the persistence layer: no `locate`, no dependency on which backend is mounted.
+
+Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a synchronous lookup in the domain's in-memory tables (zero I/O), and every write queues on the domain's per-unit write chain, mutating memory only after durability — no direct disk reads that could lag the throttled writes. The cache keeps every other responsibility: checkpoint fold, write policy (turn/end + disposal mandatory, count/interval throttle), fail-soft durability, and the listing read. `cachedSnapshot(meta)` is synchronous. The cache runs no cold-refold ladder (that would require reading the session log, which belongs to the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. The json backend creates its tree owner-only (`0o700`).
+
+## Consequences
+
+- Per-session write isolation: each throttled write replaces only that session's small document, removing the global write amplification. The domain write chain serializes writes, so a newer cut never lands before an older one; domain close drains in-flight writes.
+- Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column.
+- ACP, headless, SDK, and Web sessions publish cache rows for later consumers. The log-leading durability barrier may flush a covered prefix at the cache cadence and split otherwise coalesced physical JSONL runs; recorded profile snapshots re-pack the logical event stream so cache timing does not define fixture layout.
+- The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain.
+- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The legacy file remains untouched.
+- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id.
+
+## Alternatives considered
+
+- **Keep the global sessions table.** Preserves one-load listing, but keeps the global write amplification and single-file blast radius that motivated the change.
+- **Cache-owned per-session files** (`<root>/<session-id>/projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent.
+- **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout.
+- **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded.

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:投影缓存改为每会话文件
+
+Status: implemented
+
+[English](2026-08-19-projection-cache-per-session-files.md) | 中文
+
+## Problem
+
+持久投影缓存曾是单个全局 `session_projcache.json`——存储根目录下一个文件里的 `sessions` 表。每次节流检查点都会重写包含所有会话行的整个文件,写放大随会话数量增长;且一个畸形文件会让整个缓存一起失效。
+
+## Decision
+
+缓存打开采用新增 `per-record` 布局的 `session_projcache` 存储域:每个会话一个带版本戳的文档,位于 `<root>/session_projcache/sessions/<id>.json`,介质归存储栈所有——`storage` / `storage-json` / `storage-domain` 与缓存一起落在共享 base 装配里,缓存重新变回纯粹的域消费方。所有随附且基于 base 的 profile 都保持启用缓存,因此会话生产方会记录检查点,不取决于当前应用是否提供列表接口;不使用 base 组合包的 `sdk-minimal` 不在此装配范围内。缓存绝不咨询持久化层:没有 `locate`、不依赖挂载的是哪个后端。
+
+读写共享同一份一致状态:每次读取(`cachedSnapshot`)都是对域内存表的同步查找(零 I/O);每次写入排进该域的单条写链,先落盘成功才改内存——不再有落后于节流写入的直读磁盘。缓存保留其余全部职责:检查点折叠、写策略(turn/end + dispose 强制点、count/interval 节流)、fail-soft 持久化与列表读。`cachedSnapshot(meta)` 是同步的。缓存不运行冷重折叠阶梯(那需要读取会话日志,属于持久化层的职责);需要保证冷快照的消费方自行从日志重折叠。json 后端以仅属主权限(`0o700`)创建自己的目录树。
+
+## Consequences
+
+- 每会话写入隔离:每次节流写入只替换该会话的小文档,消除全局写放大。域写链将写入串行化,新切面绝不会先于旧切面落盘;域关闭时会排空在途写入。
+- 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。
+- ACP、headless、SDK 与 Web 会话都会发布缓存行,供后续消费方使用。确保日志领先的持久性屏障可能按缓存节奏 flush 已覆盖的前缀,并拆分原本会合并的物理 JSONL 行;各 profile 的录制快照会重新 pack 逻辑事件流,因此缓存时序不会决定 fixture 布局。
+- per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。
+- json 后端仅在枚举时没有发现任何新布局文档路径,才从旧整单元缓存引导 per-record 目录树。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。旧文件保持不变。
+- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。
+
+## Alternatives considered
+
+- **保留全局 sessions 表。** 保留一次加载式列表,但保留了促成此改动的全局写放大与单文件爆炸半径。
+- **缓存自持的每会话文件**(`<root>/<session-id>/projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。
+- **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。
+- **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
-2026-07-31-resume-selector-batch-projection.md: 5b0b33c34137efb95f31e07e7fce7622aeb2dbce
-2026-07-31-resume-selector-batch-projection.zh.md: a8e89c2e989680a1d1bf98f0cd2817b5900d36d0
+2026-07-31-resume-selector-batch-projection.md: 387d05e055c2b90f3aa7ee39d624c117ba54b4b1
+2026-07-31-resume-selector-batch-projection.zh.md: febd744b3f7ec58dab94f5d8437feaa270dfffcf

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md

@@ -12,7 +12,7 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per
 
 Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
 
-- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
+- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, one file read per session), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
 - The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp.
 - The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
 
-- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的「Unreadable session」回退。
+- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,每会话一次文件读取),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描每会话一次文件读取。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的「Unreadable session」回退。
 - 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
 - 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.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-06-22-acp-subagent-backend.md
-2026-06-22-acp-subagent-backend.md: 41a60bc8e73cbda1d382226acfbd9c0a4b146fa6
-2026-06-22-acp-subagent-backend.zh.md: 3d6f98f3c168603591da5d1293adfe52463dc8d5
+2026-06-22-acp-subagent-backend.md: 85129ec6a5b1c5607ef3a89e6ebaf6cbfb29e7c9
+2026-06-22-acp-subagent-backend.zh.md: ed0b3488a91782cc1249f65e95935e2f7c00e9c3

+ 3 - 2
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md

@@ -30,7 +30,7 @@ The child's working directory is an explicit resolution, never the harness proce
 
 ### StopReason mapping
 
-ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested); `result` never rejects on a child-level failure, per the seam contract.
+ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. Spawn, initialize, and session-creation failures reject `start()` before publication after provider-owned cleanup; prompt/RPC/transport failures after publication settle `result` as `error` (or `aborted` after local cancellation), and `result` never rejects on a child-level failure. Non-completed and lifecycle failures add only the bounded provider stage, coarse category, closed permission decision, and observed process facts defined by the [out-of-process diagnostics decision](2026-08-21-out-of-process-subagent-minimal-diagnostics.md); raw ACP errors and stderr remain Host-only.
 
 ### Security: scrubbed child environment
 
@@ -41,7 +41,8 @@ The child is a separate process, so it inherits an environment. Credential-shape
 - **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports.
 - **Keyless Loader composition:** A test-only cordis.yml boots the stdio app through the real Loader with the backend's `cwd` omitted; a scripted model delegates once and the scripted child proves it ran in — and was announced — the parent session's workspace (the cwd-inheritance branch end to end).
 - **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file.
-- **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child.
+- **Keyless snapshot:** The ACP example boots the real provider and scripted child through Loader-backed replay, pinning foreground and one-shot background diagnostics while keeping the child process, permission decision, partial output, and cleanup lifecycle deterministic.
+- **Snapshot gap:** Each ACP child still has its own replay session; `TODO(acp-subagent-replay)` continues to track parent replay against a replaying child harness rather than the scripted protocol child used by the diagnostic scenario.
 
 ## Alternatives considered
 

+ 3 - 2
.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md

@@ -30,7 +30,7 @@ subagent seam([seam Agent Note](2026-06-21-subagent-capability-seam.zh.md))
 
 ### StopReason 映射
 
-ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义,任务未完成)、未知→`error`。spawn/传输/RPC 失败时,结果为 `error`(如果已请求取消则为 `aborted`);按 seam 约定,`result` 在子 agent 级别失败时从不 reject。
+ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`max_tokens`→`max-tokens`、`refusal`→`refusal`、`cancelled`→`aborted`、`max_turn_requests`→`error`(无对等语义,任务未完成)、未知→`error`。spawn、initialize 与会话创建失败会在提供方自有清理后、发布前拒绝 `start()`;发布后的 prompt/RPC/传输失败会把 `result` 确定为 `error`(本地取消后为 `aborted`),而 `result` 在子 agent 级别失败时绝不 reject。非完成结果与生命周期失败只会附加[进程外诊断决策](2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md)定义的有界 provider stage、粗粒度 category、闭集权限决定和已观测进程事实;原始 ACP 错误与 stderr 仍只留在 Host。
 
 ### 安全:清洗子进程环境
 
@@ -41,7 +41,8 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、`
 - **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试提示词输入/输出流程、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、会话前竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
 - **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。
 - **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。
-- **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。已有确定性 mock 服务器覆盖;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。
+- **无密钥快照:** ACP 示例通过 Loader 支持的回放启动真实提供方与脚本化子进程,固定前台和一次性后台诊断,同时保持子进程、权限决定、部分输出与清理生命周期确定。
+- **快照缺口:** 每个 ACP 子 agent 仍拥有自己的回放会话;`TODO(acp-subagent-replay)` 继续跟踪父进程对回放中子 harness 的回放,而不是诊断场景使用的脚本化协议子进程。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
-2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e1e0d044089d3c7aacdbe98e8556669f28d552c5
-2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1d1b5bf5b8ae4d5403eee4d8e98c2ad9b8d0c861
+2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: c410e7ffadb448bee08c9a3e296ba71aae679f2d
+2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 13b7a498574a3d72808d24fb284da95ee00912a1

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 2
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 2
.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
-2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 533ace5a13df75fb594e0cecc65a743df27b6baf
-2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: fe8adf764b240d77cfcde95999ee6689bf11b4a4
+2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 82f2d2b131c5e991891b030d7c48873aa803386b
+2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: 4cf5a1ebef0ba83a241dec5a9d8cd21ee2010671

+ 21 - 8
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md

@@ -6,13 +6,13 @@ English | [中文](2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md)
 
 ## Problem
 
-An ACP child can stop because it reached a remote limit, denied a required permission, lost its protocol transport, or exited as a process. The shared result historically reduced these outcomes to a stop reason such as `error`, while startup and cleanup rejection messages could expose the original exception. A parent could not choose between narrowing the task, adjusting permission policy, or repairing the child deployment without Host logs.
+An ACP or DSH SDK child can stop because it reached a remote limit, denied a required permission, ended with a non-completed child turn, lost its protocol transport, or exited as a process. The shared result historically reduced these outcomes to a stop reason such as `error`, while startup and cleanup rejection messages could expose the original exception. A parent could not choose between narrowing the task, adjusting permission policy, or repairing the child deployment without Host logs.
 
 Copying exceptions, stderr, task content, tool input, paths, environment values, credentials, or protocol payloads into `SubagentResult.diagnostic` would make untrusted child text model-visible. Reusing a complete product-specific error union would also duplicate independently versioned authorities in the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md).
 
 ## Decision
 
-Each out-of-process provider owns a small mapping from facts it already receives at its protocol and process lifecycle points to fixed safe display text. The ACP provider implements that rule from its closed stop reasons, current operation, closed tool kind, configured permission policy, selected permission outcome, and the managed subprocess exit code or signal. Consumers continue to use the existing optional `SubagentResult.diagnostic`; they do not parse its punctuation or provider-private category names.
+Each out-of-process provider owns a small mapping from facts it already receives at its protocol and process lifecycle points to fixed safe display text. The ACP provider derives it from closed stop reasons, current operation, closed tool kind, configured permission policy, selected permission outcome, and the managed subprocess exit code or signal. The DSH SDK provider derives it from the child `turn/end` reason, current SDK operation, and exported SDK error class. Consumers continue to use the existing optional `SubagentResult.diagnostic`; they do not parse its punctuation or provider-private category names.
 
 ### Safe failure text
 
@@ -38,21 +38,34 @@ When an ACP permission request contributes to a non-completed result, a fixed li
 
 `max_turn_requests` remains the shared `error` stop reason and adds `remote-limit`. An unknown stop reason remains `error` and becomes the fixed `unknown` category without copying the value. `max_tokens`, `refusal`, and `cancelled` keep their existing shared stop reasons; they add a diagnostic only when a permission decision must be explained.
 
+### DSH SDK facts
+
+| Stage | Owned operation | Safe categories and facts |
+| --- | --- | --- |
+| `initialize` | Parent workspace resolution, SDK runtime spawn, and initialize handshake | `configuration`, `protocol`, `transport`, or `unknown` |
+| `session-run` | Prompt acceptance, session notifications, and final child reason | `child-error`, `child-disposed`, `child-unknown`, `missing-terminal`, `protocol`, `transport`, or `unknown` |
+| `shutdown` | Bounded SDK shutdown and runtime process release | `unknown`; protocol-shutdown failures remain Host-only in the SDK client |
+
+Child `completed`, `max-tokens`, and ordinary `aborted` results keep their existing shared stop reasons without extra text. An `aborted` turn whose closed cause is `disposed` keeps `aborted` and adds `child-disposed`. `blocked` reuses `refusal`; `error` adds `child-error`. Persistence repair alone produces `interrupted`, so this fresh-session provider leaves it as generic `error` without a diagnostic. A missing terminal event adds `missing-terminal`; an unknown reason uses `child-unknown` without copying the value or the child's structured failure message.
+
+During initialize or session run, `SdkProtocolError` and JSON-RPC error responses map to `protocol`, and `TransportClosedError` maps to `transport`; the provider never reads their messages. Other exceptions and shutdown rejection use `unknown`. Request timeout classification remains deferred because this provider does not configure or propagate a request timeout.
+
 ### Ownership and lifecycle
 
 | Fact or resource | Owner | Consumer behavior |
 | --- | --- | --- |
-| ACP stop reason and tool kind | ACP server and SDK | The provider maps only closed values and uses fixed unknown fallbacks |
-| Current failure stage and latest permission decision | One ACP run | Derived at the failure point and discarded with the run; concurrent runs share no diagnostic state |
-| Exit code and signal | `dsh-subprocess` handle | Displayed only after the managed outcome is observed; stderr is never parsed |
+| Protocol terminal fact | ACP server or child Harness Session | Each provider maps only its owned closed values and uses fixed unknown fallbacks |
+| Current failure stage and operation-local detail | One provider run | Derived at the failure point and discarded with the run; concurrent runs share no diagnostic state |
+| Exit code and signal | ACP's `dsh-subprocess` handle | Displayed only after the managed outcome is observed; stderr is never parsed |
+| SDK error category | TypeScript SDK client error class | Classified with `instanceof`; the Error message and stderr tail remain internal |
 | Diagnostic bytes and presentation | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text stays separate from assistant output in foreground and one-shot background modes |
 | Raw failure | Child runtime, Error cause chain, and Host logger | Available for Host diagnosis only, never copied into the parent model result |
 
-Startup publishes no run until initialize and new-session succeed. A startup failure rolls the private child back to quiescence before rejecting with safe facts. A published run settles its result without rejection, and `dispose()` independently reports a safe teardown failure while still using the backend's existing whole-tree cleanup ladder.
+Startup publishes no run until the provider's handshake completes. Successful startup cleanup rolls the private child back to quiescence before rejection. Cleanup failure preserves startup plus teardown/shutdown for an ordinary failure, or cleanup alone after cancellation, without claiming complete managed-process quiescence. A published run settles its result without rejection, and `dispose()` independently reports safe teardown or shutdown facts while still using the backend's existing process cleanup ladder.
 
 ## Verification
 
-ACP package tests drive a real stdio protocol child and pin every stop-reason mapping, remote-limit and unknown fallbacks, permission allow/deny facts, configuration, initialize, new-session, prompt, process, and teardown stages, startup rollback, successful-result and local-cancellation omission, partial output, concurrent-run isolation, Host-only raw errors, process quiescence, and the shared multibyte diagnostic limit. A Loader composition proves the real configured provider reaches the model-visible foreground result. The keyless ACP snapshot pins the same diagnostic and permission fact in foreground error output and one-shot background `job_output` detail.
+ACP package tests drive a real stdio protocol child and pin every stop-reason mapping, remote-limit and unknown fallbacks, permission allow/deny facts, configuration, initialize, new-session, prompt, process, and teardown stages, startup rollback, successful-result and local-cancellation omission, partial output, concurrent-run isolation, Host-only raw errors, process quiescence, and the shared multibyte diagnostic limit. DSH SDK package tests drive the real SDK client against its stdio fake runtime and pin every reachable child reason, current typed SDK category, initialize/session-run/shutdown stages, SDK-owned failed-start cleanup, cancellation cleanup, partial output, concurrency, sanitization, and quiescence. Loader compositions prove each real configured provider reaches the model-visible foreground result. Keyless ACP and JSON-RPC snapshots pin each provider's exact foreground and one-shot background diagnostic text.
 
 ## Alternatives considered
 
@@ -68,6 +81,6 @@ ACP package tests drive a real stdio protocol child and pin every stop-reason ma
 
 ## Consequences
 
-The parent can distinguish an ACP remote limit, permission involvement, protocol or transport failure, deployment/process failure, and teardown failure without receiving child-controlled text. Startup and cleanup errors use the same safe facts as published results, while Host observation retains the original cause.
+The parent can distinguish an ACP remote limit or permission decision and a DSH child-turn, protocol, transport, or shutdown failure without receiving child-controlled text. Startup and cleanup errors use the same safe facts as published results, while Host observation retains the original cause.
 
 The diagnostic remains display text rather than a public protocol. Consumers may present it but must not branch on its format. This decision adds no retry policy, recovery controller, shared provider-error enum, stderr classifier, authentication taxonomy, session persistence, progress stream, or new ACP capability.

+ 21 - 8
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## Problem
 
-ACP 子进程可能因为达到远端限制、拒绝必需权限、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
+ACP 或 DSH SDK 子进程可能因为达到远端限制、拒绝必需权限、以非完成子轮次结束、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
 
 若把异常、stderr、任务内容、工具输入、路径、环境值、凭证或协议 payload 复制进 `SubagentResult.diagnostic`,不受信任的子进程文本就会变成模型可见内容。若复用完整的产品专属错误联合,又会在提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.zh.md) 中复制彼此独立版本化的权威。
 
 ## Decision
 
-每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来实现该规则。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
+每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来派生。DSH SDK 提供方使用子 `turn/end` 原因、当前 SDK 操作与导出的 SDK 错误 class 来派生。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
 
 ### 安全失败文本
 
@@ -38,21 +38,34 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
 
 `max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。
 
+### DSH SDK 事实
+
+| Stage | 归属操作 | 安全 category 与事实 |
+| --- | --- | --- |
+| `initialize` | 父工作区解析、SDK 运行时 spawn 与 initialize 握手 | `configuration`、`protocol`、`transport` 或 `unknown` |
+| `session-run` | prompt 接受、会话通知与最终子轮次原因 | `child-error`、`child-disposed`、`child-unknown`、`missing-terminal`、`protocol`、`transport` 或 `unknown` |
+| `shutdown` | 有界 SDK shutdown 与运行时进程释放 | `unknown`;协议 shutdown 失败仍留在 SDK 客户端的 Host 诊断中 |
+
+子 `completed`、`max-tokens` 与普通 `aborted` 结果保持既有共享结束原因,不附加文本。闭集原因是 `disposed` 的 `aborted` 轮次仍保持 `aborted`,并附加 `child-disposed`。`blocked` 复用 `refusal`;`error` 附加 `child-error`。只有持久化修复会产生 `interrupted`,因此本全新会话提供方把它保留为不带诊断的通用 `error`。缺失终态事件会附加 `missing-terminal`;未知原因使用 `child-unknown`,且不复制原值或子进程结构化失败消息。
+
+在 initialize 或 session run 期间,`SdkProtocolError` 与 JSON-RPC 错误响应映射为 `protocol`,`TransportClosedError` 映射为 `transport`;提供方绝不读取其消息。其他异常和 shutdown 拒绝使用 `unknown`。由于本提供方没有配置或传播 request timeout,请求超时分类继续推迟。
+
 ### 所有权与生命周期
 
 | 事实或资源 | Owner | 消费方行为 |
 | --- | --- | --- |
-| ACP 结束原因与工具种类 | ACP server 与 SDK | 提供方只映射闭集值,并对闭集外值使用固定 unknown 回退 |
-| 当前失败 stage 与最新权限决定 | 单次 ACP 运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
-| 退出码与信号 | `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
+| 协议终态事实 | ACP server 或子 Harness Session | 每个提供方只映射自身拥有的闭集值,并使用固定 unknown 回退 |
+| 当前失败 stage 与 operation-local 细节 | 单次提供方运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
+| 退出码与信号 | ACP 的 `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
+| SDK 错误 category | TypeScript SDK 客户端错误 class | 仅通过 `instanceof` 分类;Error 消息和 stderr tail 留在内部 |
 | 诊断字节与呈现 | `dsh-subagent`、前台工具与 Job 运行时 | 前台和一次性后台模式都把同一份有界文本与 assistant 输出分开 |
 | 原始失败 | 子运行时、Error cause 链与 Host logger | 只供 Host 排障,绝不复制进父模型结果 |
 
-启动只有在 initialize 与 new-session 成功后才发布运行。启动失败会先把私有子进程回滚到完全停稳,再以安全事实拒绝。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 失败,并继续使用后端既有的整棵进程树清理阶梯。
+启动只有在提供方握手完成后才发布运行。启动清理成功时,私有子进程会先回滚到完全停稳再拒绝。清理失败时,普通失败会保留启动与 teardown/shutdown,取消后只保留清理事实,且不会宣称受管进程已经完全停稳。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 或 shutdown 事实,并继续使用后端既有的进程清理阶梯。
 
 ## Verification
 
-ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。Loader 组合证明真实配置的提供方会到达模型可见前台结果。无密钥 ACP snapshot 会在前台错误输出与一次性后台 `job_output` detail 中固定同一份诊断与权限事实。
+ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。DSH SDK 包测试通过真实 SDK 客户端驱动其 stdio 伪运行时,固定全部可达子轮次原因、当前 typed SDK category、initialize/session-run/shutdown stage、SDK 自有失败启动清理、本地取消清理、部分输出、并发、脱敏与停稳。Loader 组合证明两个真实配置的提供方都能到达模型可见前台结果。无密钥 ACP 与 JSON-RPC snapshot 会固定各自提供方的准确前台与一次性后台诊断文本。
 
 ## Alternatives considered
 
@@ -68,6 +81,6 @@ ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、
 
 ## Consequences
 
-父 agent 可以区分 ACP 远端限制、权限参与、协议或传输失败、部署/进程失败与 teardown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
+父 agent 可以区分 ACP 远端限制或权限决定,以及 DSH 子轮次、协议、传输或 shutdown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
 
 诊断仍是展示文本,不是公共协议。消费方可以呈现它,但不得按格式分支。本决策不增加重试策略、恢复控制器、共享提供方错误 enum、stderr 分类器、认证分类、会话持久化、进度流或新的 ACP 能力。

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
-2026-07-28-storage-root-and-derived-medium-recovery.md: cfed831be7eb0fceb5ef7d9778803c602e809179
-2026-07-28-storage-root-and-derived-medium-recovery.zh.md: e535cf703e55b03ccd9a767e03aa73f621b17466
+2026-07-28-storage-root-and-derived-medium-recovery.md: 1505be1c58d5cf829327b2919113bb2e42798ce7
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 1bab5ab663df1419cc826c3d6acb59bd8bff7de0

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md

@@ -10,7 +10,7 @@ The persisted projection cache ([note](2026-07-27-session-projection-and-command
 
 **Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `<launch dir>/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session/session-persistence-jsonl/src/index.ts).
 
-**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
+**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
 
 ## Proposal
 

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md

@@ -10,7 +10,7 @@ Status: proposed
 
 **文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。
 
-**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
+**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
 
 ## 提案
 

+ 12 - 0
apps/cli/composition.md

@@ -56,6 +56,14 @@ flowchart LR
   cfg --> plugin_dsh_base_session_query_sqlite
   plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
   cfg --> plugin_dsh_base_session_projection
+  plugin_dsh_base_storage["storage<br/>@deepseek-ai/dsh-storage"]
+  cfg --> plugin_dsh_base_storage
+  plugin_dsh_base_storage_json["storage-json<br/>@deepseek-ai/dsh-storage-json"]
+  cfg --> plugin_dsh_base_storage_json
+  plugin_dsh_base_storage_domain["storage-domain<br/>@deepseek-ai/dsh-storage-domain"]
+  cfg --> plugin_dsh_base_storage_domain
+  plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
+  cfg --> plugin_dsh_base_session_projection_cache
   plugin_dsh_base_session_telemetry_otel["session-telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
   cfg --> plugin_dsh_base_session_telemetry_otel
   plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
@@ -200,6 +208,10 @@ flowchart LR
 | `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
 | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
 | `session-projection` | `@deepseek-ai/dsh-session-projection` |
+| `storage` | `@deepseek-ai/dsh-storage` |
+| `storage-json` | `@deepseek-ai/dsh-storage-json` |
+| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
+| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
 | `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |

+ 1 - 1
apps/cli/tests/built-bin.e2e.ts

@@ -392,7 +392,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     } finally {
       rmSync(home, { recursive: true, force: true })
     }
-  }, 30_000)
+  }, process.platform === 'win32' ? 60_000 : 30_000)
 
   it('reports SDK startup failure when stdin reaches EOF first', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))

+ 21 - 0
apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts

@@ -0,0 +1,21 @@
+/** Release the gated background SDK child only after job_output starts waiting. */
+
+import { writeFileSync } from 'node:fs'
+import type { Context } from '@deepseek-ai/cordis'
+
+export const name = 'subagent-dsh-sdk-release-on-job-output'
+export const inject = ['tools']
+
+/**
+ * Register the test-only execution-order barrier.
+ * @param ctx - parent runtime context carrying the tool execution waterfall.
+ */
+export function apply(ctx: Context): void {
+  ctx.on('tools/execute', async (exec, next) => {
+    const delegated = next()
+    if (exec.name === 'job_output') {
+      writeFileSync('.dsh-sdk-background-release', 'release\n')
+    }
+    return delegated
+  }, { prepend: true })
+}

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

@@ -15,7 +15,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
+  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
 } from '@deepseek-ai/dsh-session'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import {
@@ -87,7 +87,7 @@ function seedLog(): string {
 async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> {
   const childId = sessionId('agent-preset-selection-child')
   const createdAt = 1784974100100
-  await scaffold.ctx.sessionPersistence.create({
+  const header: SessionHeader = {
     version: SESSION_FORMAT_VERSION,
     id: childId,
     createdAt,
@@ -96,7 +96,8 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
     origin: 'subagent',
     delegationDepth: 1,
     agentPreset: 'minimal',
-  })
+  }
+  await scaffold.ctx.sessionPersistence.create(header)
   await scaffold.ctx.sessionPersistence.append(childId, [
     {
       type: 'turn/start',
@@ -129,7 +130,6 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
       data: { turn: 1, reason: { kind: 'completed' } },
     },
   ] as SessionEvent[])
-  await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
 }
 
 /**

+ 65 - 10
apps/web/tests/minimal-preset.snapshot.ts

@@ -1,25 +1,41 @@
 import { mkdir, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { AgentHandle } from '@deepseek-ai/dsh-agent'
 import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/minimal-preset', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
-const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.'
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+const PROMPT = "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop."
 
 describe('minimal agent preset', () => {
   let scaffold: WebScaffold
   let agentHandle: AgentHandle
   let disposeInjectedPrompt: () => void
+  let browser: Browser | undefined
+  let page: Page | undefined
+  let tripwire: ReturnType<typeof watchConsole> | undefined
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: true })
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: true, paceMs: 10 })
     disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({
       name: 'test:injected-prompt',
       order: 999,
@@ -31,10 +47,17 @@ describe('minimal agent preset', () => {
       agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
       setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
     })
+    agentHandle.agent.followup(createUserMessage({
+      content: [{ type: 'text', text: PROMPT }],
+      source: { kind: 'user' },
+    }))
+    await agentHandle.agent.whenIdle()
   })
 
   afterAll(async () => {
     const failures: unknown[] = []
+    await page?.close().catch((error: unknown) => failures.push(error))
+    await browser?.close().catch((error: unknown) => failures.push(error))
     await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
     try {
       disposeInjectedPrompt?.()
@@ -47,12 +70,6 @@ describe('minimal agent preset', () => {
   })
 
   it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
-    agentHandle.agent.followup(createUserMessage({
-      content: [{ type: 'text', text: PROMPT }],
-      source: { kind: 'user' },
-    }))
-    await agentHandle.agent.whenIdle()
-
     const requestHeader = agentHandle.agent.session.requestHeader()
     if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
     expect(agentHandle.agent.session.events.some(event => event.type === 'user/message'
@@ -119,10 +136,48 @@ describe('minimal agent preset', () => {
     `)
     expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name)))
       .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name)))
+  })
+
+  it.skipIf(MODE === 'record')('expands the completed persistent Bash call in the Web conversation', async () => {
+    onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-persistent-bash-card') })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.waitFor({ timeout: 15_000 })
+    await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 10_000 })
+    await sessionRow.click()
+    await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 })
+
+    const row = page.locator('[data-sample="bash"]').first()
+    await row.waitFor({ timeout: 15_000 })
+    await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
+    await row.click()
+
+    await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
+    const call = row.locator('xpath=..')
+    await call.getByText('IN', { exact: true }).waitFor()
+    await call.getByText('OUT', { exact: true }).waitFor()
+    await call.getByText('MINIMAL_BASH_CARD_OK', { exact: true }).waitFor()
+    await call.getByText(/"command": "printf 'MINIMAL_BASH_CARD_OK/).waitFor()
+
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
+  it('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.jsonl',
       'system-prompt.expected.md',
       'tool-schemas.expected.json',
+      'ui.expected.md',
     ])
   })
 })

+ 29 - 11
apps/web/tests/subagent-conversation.e2e.ts

@@ -6,7 +6,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
+  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
 } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-agent'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
@@ -58,6 +58,18 @@ async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promi
   }
 }
 
+/** Poll until the cold-read write-back of {@link coldSnapshot} lands a visible row. */
+async function waitForCacheRow(
+  scaffold: WebScaffold,
+  header: SessionHeader,
+): Promise<void> {
+  const deadline = Date.now() + 10_000
+  while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) {
+    if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`)
+    await new Promise<void>(resolve => setTimeout(resolve, 10))
+  }
+}
+
 describe('web e2e: persisted subagent conversation and human continuation', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -114,7 +126,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     oneShotId = sessionId('recorded-one-shot')
     const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
     const oneShotAt = Date.now() - oneShotDurationMs
-    await scaffold.ctx.sessionPersistence.create({
+    const oneShotHeader: SessionHeader = {
       version: SESSION_FORMAT_VERSION,
       id: oneShotId,
       createdAt: oneShotAt,
@@ -122,8 +134,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: parent.id,
       origin: 'subagent',
       delegationDepth: 1,
-    })
-    await scaffold.ctx.sessionPersistence.append(oneShotId, [
+    }
+    await scaffold.ctx.sessionPersistence.create(oneShotHeader)
+    const oneShotEvents = [
       {
         type: 'turn/start',
         seq: 0,
@@ -154,11 +167,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         time: oneShotAt + oneShotDurationMs,
         data: { turn: 1, reason: { kind: 'completed' } },
       },
-    ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
+    ] as SessionEvent[]
+    await scaffold.ctx.sessionPersistence.append(oneShotId, oneShotEvents)
+    scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, oneShotEvents)
+    await waitForCacheRow(scaffold, oneShotHeader)
     grandchildId = sessionId('recorded-grandchild')
     const authoredAt = Date.now()
-    await scaffold.ctx.sessionPersistence.create({
+    const grandchildHeader: SessionHeader = {
       version: SESSION_FORMAT_VERSION,
       id: grandchildId,
       createdAt: authoredAt,
@@ -166,8 +181,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: childId,
       origin: 'subagent',
       delegationDepth: 2,
-    })
-    await scaffold.ctx.sessionPersistence.append(grandchildId, [
+    }
+    await scaffold.ctx.sessionPersistence.create(grandchildHeader)
+    const grandchildEvents = [
       {
         type: 'turn/start',
         seq: 0,
@@ -198,8 +214,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         time: authoredAt + 3,
         data: { turn: 1, reason: { kind: 'completed' } },
       },
-    ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
+    ] as SessionEvent[]
+    await scaffold.ctx.sessionPersistence.append(grandchildId, grandchildEvents)
+    scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, grandchildEvents)
+    await waitForCacheRow(scaffold, grandchildHeader)
     expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
     expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
     expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()

+ 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: d8b931bbe06358f28dd7123489bfb8bb9fde82ba
-config-catalog.zh.md: ab7abc48f1a4e232f95cd8adc7cd3447529f8628
+config-catalog.md: f3acc7b96bc497c54edfff83c80226e5167cac69
+config-catalog.zh.md: df13a3ed37d5343b069c3a6a00b896197b2d729b

+ 8 - 7
docs/config-catalog.md

@@ -1822,14 +1822,15 @@ Source: [`packages/session/session-persistence-sqlite/src/index.ts:38`](../packa
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
+Requires: `storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -1839,7 +1840,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2175,12 +2176,12 @@ Requires: `storage`
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+Source: [`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 
@@ -2397,7 +2398,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:33`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
+Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:34`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
 
 <a id="deepseek-aidsh-subagent-fork-in-process"></a>
 

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

@@ -1824,14 +1824,15 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-需要:`storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
+需要:`storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -1841,7 +1842,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
+来源:[`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2177,12 +2178,12 @@ export interface Config {
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-来源:[`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+来源:[`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 
@@ -2399,7 +2400,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/subagent/subagent-dsh-sdk/src/index.ts:33`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
+来源:[`packages/subagent/subagent-dsh-sdk/src/index.ts:34`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
 
 <a id="deepseek-aidsh-subagent-fork-in-process"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: bfc43f07617753775ff189a75f49007c2895fee0
-event-producer-consumer.zh.md: 36e8b5d6c644de99c9c6d857029265af2a9742de
+event-producer-consumer.md: 81ea525cb600bbe381a3326d4d153cb43277de6c
+event-producer-consumer.zh.md: 79a9c1d56b8fefa5ffb02fda0e48d7b75b5fed83

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

@@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

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

@@ -47,7 +47,7 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

+ 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: 6e3e76e26609039f8c3ad946e1200816b4372bf9
-module-graph.zh.md: 861a9fd90117ec9a677322d8653384927b2bd2df
+module-graph.md: b6a0176455e29f5166d3a1f0f6c340d48cfc3172
+module-graph.zh.md: f4f13cce0be3a3c4d20e9bc2bbeb0dc5272cce5e

+ 1 - 2
docs/module-graph.md

@@ -528,7 +528,6 @@ flowchart TD
   pkg_session_persistence_sqlite --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_invariants
   pkg_session_projection_cache --> pkg_session
-  pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
   pkg_session_stats --> pkg_invariants
@@ -1742,7 +1741,7 @@ flowchart TD
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |

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

@@ -530,7 +530,6 @@ flowchart TD
   pkg_session_persistence_sqlite --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_invariants
   pkg_session_projection_cache --> pkg_session
-  pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
   pkg_session_stats --> pkg_invariants
@@ -1744,7 +1743,7 @@ flowchart TD
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |

+ 2 - 2
docs/subsystems/session-projection.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/session-projection.md
-session-projection.md: 17e3ea7a2066d4ee3cd704cbc25748aabb2ece19
-session-projection.zh.md: 2bdeaee3f33aec4d403339efe6d564617f12a82b
+session-projection.md: 174223980415b5f1580bb1c7c989b8e90ea7b277
+session-projection.zh.md: 73b0613bfd9f9ae405df909845d977977404c7f0

+ 21 - 20
docs/subsystems/session-projection.md

@@ -113,17 +113,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.
+The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
  * The zero-I/O listing read: whole values viewed straight from the stored
- * rows (version-matching keys only), each cut carried with its watermark
- * so a client value store can seed under its higher-seq-wins rule — as
- * stale as the last durable checkpoint but never wrong, and never from an
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
  * unrelated log (the caller's header is the identity witness). Fresher
- * paths (the history tail baseline, {@link coldSnapshot}) supersede these
- * values whenever a session is actually opened.
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -146,29 +146,30 @@ hydratePrepared( session: Session, meta: SessionHeader, events: readonly Session
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
- * this boundary (states are live references), then the whole record is
- * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
 async write(session: Session): Promise<void>
 
 /**
- * Cold-read one persisted session's projections with zero full-log load:
- * cached rows + a persistence `readFrom` tail from the registry's restore
- * floor, refolded by the registry and written back (fail-soft) so the next
- * cold read starts closer. A cache row invalidated by a shrunk log
- * (crash-repair truncation) triggers one full re-read from seq 0 — the
- * ladder's slow rung, still no crash. Rejects when the session has no
- * persisted log (`not found` from the persistence seam).
- * @param id - the persisted session to read.
- * @param signal - optional cancellation for the persistence reads.
- * @returns the snapshot cut at the stored log end.
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
 ```
 
-Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 

+ 21 - 20
docs/subsystems/session-projection.zh.md

@@ -113,17 +113,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.
+The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
  * The zero-I/O listing read: whole values viewed straight from the stored
- * rows (version-matching keys only), each cut carried with its watermark
- * so a client value store can seed under its higher-seq-wins rule — as
- * stale as the last durable checkpoint but never wrong, and never from an
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
  * unrelated log (the caller's header is the identity witness). Fresher
- * paths (the history tail baseline, {@link coldSnapshot}) supersede these
- * values whenever a session is actually opened.
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -146,29 +146,30 @@ hydratePrepared( session: Session, meta: SessionHeader, events: readonly Session
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
- * this boundary (states are live references), then the whole record is
- * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
 async write(session: Session): Promise<void>
 
 /**
- * Cold-read one persisted session's projections with zero full-log load:
- * cached rows + a persistence `readFrom` tail from the registry's restore
- * floor, refolded by the registry and written back (fail-soft) so the next
- * cold read starts closer. A cache row invalidated by a shrunk log
- * (crash-repair truncation) triggers one full re-read from seq 0 — the
- * ladder's slow rung, still no crash. Rejects when the session has no
- * persisted log (`not found` from the persistence seam).
- * @param id - the persisted session to read.
- * @param signal - optional cancellation for the persistence reads.
- * @returns the snapshot cut at the stored log end.
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
 ```
 
-Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md)
+Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 

+ 2 - 2
docs/subsystems/storage.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/storage.md
-storage.md: b850b0229ae8d1fc4fc5352000c8a4a8c06b3ade
-storage.zh.md: 0cadaa3ea302b2524c991df3e2f31bdbe0a93dee
+storage.md: 1e4141e6ef1c6f8e1c2593e21e788b626d6b1ed7
+storage.zh.md: f0433c600674741c3de0ce3e99430297839ce124

+ 8 - 0
docs/subsystems/storage.md

@@ -57,6 +57,14 @@ interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */

+ 8 - 0
docs/subsystems/storage.zh.md

@@ -57,6 +57,14 @@ interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */

+ 1 - 0
knip.json

@@ -674,6 +674,7 @@
       "entry": [
         "tests/**/*.spec.ts",
         "tests/**/*.e2e.ts",
+        "tests/profiles/**/fixtures/**/*.{ts,mjs}",
         "tests/profiles/**/tests/fixtures/**/*.{ts,mjs}"
       ],
       "project": [

+ 2 - 2
packages/bundle/acp-app/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/bundle/acp-app/README.md
-README.md: d00458d3e23cfd9ff8984454aace991d3c2f8dd9
-README.zh.md: e32eac45d407af25c474b6df483dc587dd4a5038
+README.md: 5cb1e33319c5da04de19d3c8eb1c24cb72ec2312
+README.zh.md: 13debf94c4fe209a74f0174fda7195c383bf590a

+ 1 - 1
packages/bundle/acp-app/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The automation-only ACP stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona and default model route, mounts an app-owned zero-option command provider, and starts [`dsh-acp`](../../acp/acp/README.md) only after that provider accepts the invocation. `dsh --profile acp --help` therefore writes help and exits without claiming stdin or stdout.
 
-The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. The bundle disables model-generated session titles because ACP exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
+The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. The bundle disables model-generated session titles because ACP exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints ACP-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
 
 The shipped row creates sessions with `deepseek-official` and `deepseek-v4-flash`; a later patch can replace that row's complete config. The base profile owns adapters, tools, persistence, policy, settings, credentials, and the per-session workspace supplied by the ACP client.
 

+ 1 - 1
packages/bundle/acp-app/README.zh.md

@@ -4,7 +4,7 @@
 
 以 [`dsh-base`](../base/README.zh.md) 为基础的 automation-only ACP stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona 与默认模型路由、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-acp`](../../acp/acp/README.zh.md)。因此,`dsh --profile acp --help` 会写出 help 并退出,不会占用 stdin 或 stdout。
 
-启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。ACP 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。
+启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。ACP 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 ACP 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。
 
 随附配置项使用 `deepseek-official` 与 `deepseek-v4-flash` 创建 session;后续 patch 可以替换该配置项的完整 config。base profile 负责适配器、工具、持久化、策略、settings 与 credentials;ACP client 为每个 session 提供工作区。
 

+ 27 - 0
packages/bundle/base/cordis.patch.yml

@@ -138,6 +138,33 @@
     - id: session-projection
       name: '@deepseek-ai/dsh-session-projection'
 
+    # Durable KV storage: the storage hub, the json backend, and the
+    # schema-validated domain form over them. Session-layer persistence (the
+    # projection cache below; workspace and message-feedback in web layers)
+    # routes through this stack, so it belongs to the shared base.
+    - id: storage
+      name: '@deepseek-ai/dsh-storage'
+
+    - id: storage-json
+      name: '@deepseek-ai/dsh-storage-json'
+      config:
+        root: !!js dshHomePath('storages')
+
+    - id: storage-domain
+      name: '@deepseek-ai/dsh-storage-domain'
+      config:
+        backend: json
+
+    # Persisted projection cache: throttled write-behind over the
+    # session_projcache domain (per-record layout — one version-stamped
+    # checkpoint document per session), serving the session listing's
+    # projection column.
+    - id: session-projection-cache
+      name: '@deepseek-ai/dsh-session-projection-cache'
+      config:
+        writeEveryEvents: 200
+        writeIntervalMs: 5000
+
     # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
     # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
     # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction

+ 4 - 0
packages/bundle/base/package.json

@@ -77,6 +77,7 @@
     "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
@@ -87,6 +88,9 @@
     "@deepseek-ai/dsh-skill-filesystem": "workspace:^",
     "@deepseek-ai/dsh-spill-local": "workspace:^",
     "@deepseek-ai/dsh-spill-policy": "workspace:^",
+    "@deepseek-ai/dsh-storage": "workspace:^",
+    "@deepseek-ai/dsh-storage-domain": "workspace:^",
+    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
     "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",

+ 2 - 2
packages/bundle/headless/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/bundle/headless/README.md
-README.md: 373e50c515ef45d09c32e7dd1b011f149d921250
-README.zh.md: 443945ced7e5e41899e91e8c169918f4e186de67
+README.md: 516b21ca4b31fcd8c69b7bdb1f0d5fe60d5e43e2
+README.zh.md: 37781a79629dd1a183045ef533eb9849b2b0219a

+ 1 - 1
packages/bundle/headless/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it inherits the base's disabled module-HMR policy, supplies the coding persona and tool mode, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
+The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it inherits the base's disabled module-HMR policy and projection cache, supplies the coding persona and tool mode, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs.
 
 After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. Each non-empty provider reasoning delta from that Agent is written to stderr as it arrives under a `dsh: reasoning:` heading; consecutive deltas remain one section, and the runner terminates the section before later output when the provider supplied no trailing newline. It then flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; a successful run with no reasoning keeps stderr empty. The process opens no listening port.
 

+ 1 - 1
packages/bundle/headless/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.zh.md) 之上:继承 base 默认禁用模块 HMR(热模块替换)的策略,提供编码 persona 和工具模式,将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
+dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.zh.md) 之上:继承 base 默认禁用模块 HMR(热模块替换)的策略与投影缓存,提供编码 persona 和工具模式,将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。缓存会为每个持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。
 
 Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.zh.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。该 Agent 每次产生非空的提供方推理分片时,runner 都会在 `dsh: reasoning:` 标题下将其即时写入 stderr;连续分片保留在同一段中,提供方没有输出末尾换行时,runner 会在后续输出前终止该段。随后,它对 Session 执行 flush,再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,并经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.zh.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终结束原因为 `error` 时,还会将 code 与 message 写入 stderr;没有推理内容的成功运行会保持 stderr 为空。进程不会打开监听端口。
 

+ 2 - 2
packages/bundle/sdk-app/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/bundle/sdk-app/README.md
-README.md: c5022bd2096fae931fff48944bfc167280e62476
-README.zh.md: 687a19dc09679d11696a207ad17db3cf463ee901
+README.md: 5c65c4a318683d51e930473a9715d290e331e60a
+README.zh.md: 935e2d88ce7730c6f6cef39dc9ff3d1ddb644bb4

+ 1 - 1
packages/bundle/sdk-app/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider and supplies its own profile name.
 
-The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
+The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
 
 | Config | Default | Behavior |
 |---|---|---|

+ 1 - 1
packages/bundle/sdk-app/README.zh.md

@@ -4,7 +4,7 @@
 
 以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) 组合包复用同一个启动提供方,并提供自己的 profile 名称。
 
-启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
+启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
 
 | 配置 | 默认值 | 行为 |
 |---|---|---|

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: c330e5557c2fa70f60ededcd368ec713db11269e
-README.zh.md: b2170c762a2e9425417df09e7ba6e7c16f612e96
+README.md: dd3b5787000be18920e6b773f27ebe65abe4d20a
+README.zh.md: 6e9e99b8485ebb527e69c38e28abf9c9eef44303

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
packages/bundle/web-app/README.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
packages/bundle/web-app/README.zh.md


+ 0 - 19
packages/bundle/web-app/cordis.patch.yml

@@ -49,19 +49,6 @@
     - id: code-runtime
       name: '@deepseek-ai/dsh-code-runtime-worker-thread'
 
-    - id: storage
-      name: '@deepseek-ai/dsh-storage'
-
-    - id: storage-json
-      name: '@deepseek-ai/dsh-storage-json'
-      config:
-        root: !!js dshHomePath('storages')
-
-    - id: storage-domain
-      name: '@deepseek-ai/dsh-storage-domain'
-      config:
-        backend: json
-
     - id: message-feedback
       name: '@deepseek-ai/dsh-message-feedback'
       config:
@@ -74,12 +61,6 @@
     - id: workspace
       name: '@deepseek-ai/dsh-workspace'
 
-    - id: session-projection-cache
-      name: '@deepseek-ai/dsh-session-projection-cache'
-      config:
-        writeEveryEvents: 200
-        writeIntervalMs: 5000
-
     - id: session-reference
       name: '@deepseek-ai/dsh-session-reference'
 

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

@@ -104,15 +104,11 @@
     "@deepseek-ai/dsh-file-reference-local": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
-    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-log-export": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
-    "@deepseek-ai/dsh-storage": "workspace:^",
-    "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",

+ 2 - 2
packages/client/ui-tool/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-tool/README.md
-README.md: 2db7d716dc80fbf40a953b217810fb8674e2e98f
-README.zh.md: 79ed5befe751b329984c1320144921339fdf3d3f
+README.md: 8e3b4290d2402cf0bcb907f9887ddace35b349a6
+README.zh.md: f15899aaedcd52fe7f181c163ab7ab87e9bd20b8

+ 1 - 1
packages/client/ui-tool/README.md

@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
 
 The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
 
-This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. `ui-skill` demonstrates a business-owned registration for `skill`.
 
 Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes.
 

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

@@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () =>
 
 owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
 
-本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 
 各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。
 

+ 16 - 1
packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts

@@ -205,6 +205,20 @@ function shellCall(name: string, args: Record<string, unknown>): ShellCall | nul
   }
 }
 
+/**
+ * Identify a settled root call from the persistent Bash or PowerShell tool.
+ * Its result stays on the generic input/output path because the persistent
+ * shell can report resets and partial output without one process exit status.
+ * @param block - running or settled Tool block.
+ * @returns whether the block is a settled persistent-shell call.
+ */
+export function isSettledPersistentShellCall(block: ToolCallBlock): boolean {
+  if (!('kind' in block) || block.parentCallId !== undefined) return false
+  const parsed = parsedToolCall(block)
+  if (parsed === null) return false
+  return shellCall(parsed.name, parsed.args)?.persistent === true
+}
+
 interface TerminalSendCall {
   kind: 'terminal-send'
   text: string
@@ -244,7 +258,8 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
  * Derive terminal props for supported root shell and terminal-send calls.
  * Standard shell results parse their final status marker; persistent shell
  * results, background calls, errors, malformed input, or child dispatches use
- * the generic path.
+ * the generic path. {@link isSettledPersistentShellCall} lets that generic
+ * persistent result remain expandable without inventing one process status.
  * @param block - running or settled Tool block.
  * @param sessionCwd - session workspace root used to resolve workdir.
  * @returns locale-neutral terminal-card data, or null for the generic path.

+ 12 - 8
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx

@@ -7,7 +7,11 @@ import {
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallViewProps } from '../../contract/slots.ts'
 import {
-  localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, terminalFailed,
+  isSettledPersistentShellCall,
+  localizeTerminalCardModel,
+  terminalBlockLabels,
+  terminalCardModel,
+  terminalFailed,
 } from '../models/terminal-card-model.ts'
 import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
 import { CONVERSATION_NS as NS } from '../../locale.ts'
@@ -49,13 +53,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
     : model.state
   const status = stateStatus(state, t)
   const [expanded, setExpanded] = useState(false)
-  // Execution failures (for example cancellation before the process reports a
-  // terminal result) use the generic body. Keep their recorded args and
-  // full error reachable instead of collapsing the row to the first line.
-  const genericError = terminal === null
-    && model.state === 'error'
+  // Execution failures and persistent-shell results have no terminal card.
+  // Keep their recorded args and complete output reachable through the generic
+  // body; background acknowledgements and malformed calls remain collapsed.
+  const genericBody = terminal === null
+    && (model.state === 'error' || isSettledPersistentShellCall(block))
     && (model.body !== null || model.output !== null)
-  const expandable = terminal !== null || genericError
+  const expandable = terminal !== null || genericBody
   const open = expanded && expandable
   const failureLine = model.state === 'error' ? model.errorSummary : null
   const toggleExpand = () => {
@@ -123,7 +127,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
                 {model.output !== null && (
                   <div className={css.ioSection}>
                     <span className={css.ioLabel}>{t('row.output')}</span>
-                    <span className={css.ioText} data-error>
+                    <span className={css.ioText} data-error={state === 'error' || undefined}>
                       {model.output}
                     </span>
                   </div>

+ 14 - 1
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -442,11 +442,24 @@ describe('BashRow terminal card', () => {
     expect(view.queryByText('List files')).toBeNull()
   })
 
-  it('keeps the command summary for a persistent shell with no description', () => {
+  it('expands a settled persistent shell through the generic input/output card', () => {
     const view = render(<BashRow {...rowProps(settled({
       call: { name: 'bash', argsRaw: JSON.stringify({ command: 'ls -la' }) },
     }))} />)
+    const row = view.container.querySelector('[data-sample="bash"]')!
     expect(view.getByText('ls -la')).toBeTruthy()
+    expect(row.getAttribute('role')).toBe('button')
+    expect(row.getAttribute('aria-expanded')).toBe('false')
+
+    fireEvent.click(row)
+
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText('输入')).toBeTruthy()
+    expect(view.getByText('输出')).toBeTruthy()
+    expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
+    expect(view.container.querySelector('[class*="_ioText_"][data-error]')).toBeNull()
+    expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent)
+      .toBe('a.ts  b.ts\nc.ts  d.ts\n')
   })
 
   it('a non-terminal bash call (background start) renders the summary row alone', () => {

+ 3 - 3
packages/credentials/credentials-local/tests/migration.spec.ts

@@ -4,16 +4,16 @@
 // keeps the loud rejection local.spec exercises.
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
-import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { LocalCredentialProvider, renderFlatLayoutMigration } from '../src/index.ts'
 
 /** Credential documents are seeded owner-only, exactly as the provider creates them. */
 function writeCredentials(file: string, text: string): Promise<void> {
-  return writeFile(file, text, { mode: 0o600 })
+  return writeFileAtomic(file, text, { mode: 0o600, dirMode: 0o700 })
 }
 
 const cleanups: Array<() => Promise<void>> = []

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

@@ -1396,11 +1396,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'sessionProjectionCache',
     summary: 'The persisted projection cache service.',
-    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.',
+    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.',
     methods: [
       {
         signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined',
-        description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.',
+        description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline) supersede these values whenever a session is actually opened.',
         parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }],
         returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
       },
@@ -1412,15 +1412,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async write(session: Session): Promise<void>',
-        description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the whole record is replaced. NOT fail-soft — callers on the fail-soft paths contain it.',
+        description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the session\'s record is replaced on the domain\'s write chain. NOT fail-soft — callers on the fail-soft paths contain it.',
         parameters: [{ name: 'session', description: 'the live session to checkpoint.' }],
         returns: 'resolution after durability and event emission.',
       },
       {
-        signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>',
-        description: 'Cold-read one persisted session\'s projections with zero full-log load: cached rows + a persistence `readFrom` tail from the registry\'s restore floor, refolded by the registry and written back (fail-soft) so the next cold read starts closer. A cache row invalidated by a shrunk log (crash-repair truncation) triggers one full re-read from seq 0 — the ladder\'s slow rung, still no crash. Rejects when the session has no persisted log (`not found` from the persistence seam).',
-        parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'signal', description: 'optional cancellation for the persistence reads.' }],
-        returns: 'the snapshot cut at the stored log end.',
+        signature: 'coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot',
+        description: 'Cold-read one session\'s projections from its complete log. Each unit is seeded from the identity-checked cached rows — the registry skips `apply` for the already-folded prefix (events at or below the row\'s `seq`) — and the refreshed checkpoint is written back (fail-soft, fire-and-forget), so the first cold read creates the cache row and later ones seed from it. The caller supplies the complete log in seq order: this service never consults the persistence layer.',
+        parameters: [{ name: 'meta', description: 'the stored session header (identity witness).' }, { name: 'events', description: 'the session\'s complete log, in seq order.' }],
+        returns: 'the projection cut at the log end.',
       },
     ],
   },
@@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'DomainSpec',
-    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
+    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
   },
   {
     name: 'DomainTableSpec',
@@ -3921,7 +3921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'KvUnitDescriptor',
-    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n}',
+    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n    readonly layout?: \'single\' | \'per-record\';\n}',
   },
   {
     name: 'LlmAdapter',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/sdk/client/README.md
-README.md: 4c8c77a8bbe555cb59f54bb862615d001a1c3a23
-README.zh.md: 3747243d7f2aefb4decda182963366421811b9ce
+README.md: 2df4faa99a7a81e34833a294807708dfb7ad95f8
+README.zh.md: e9c089ba17efe30fe0dd0ce7a7076aa324495009

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

@@ -26,7 +26,7 @@ const result = await harness.run('say hi')
 console.log(result.finalResponse)
 ```
 
-The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. The server validates the exact route before accepting prompts; omitting the effort preserves the model's own default. A failed handshake reaps the runtime and lets a later call retry with a fresh process until terminal `close()`. The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
+The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. The server validates the exact route before accepting prompts; omitting the effort preserves the model's own default. When a failed handshake is cleaned up successfully, the instance installs a fresh client so a later call retries with a new process until terminal `close()`. If initialization and SDK-owned cleanup both fail, `start()` rejects with an `AggregateError` whose ordered errors preserve both causes and retains the failed client rather than spawning beside a process whose exit was not proved. The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
 
 The handshake carries the absolute session workspace plus provider/model, optional `reasoningEffort`, and optional positive `maxTokens`. `run(input, { sessionId?, onNotification? })` accepts text or `SdkPromptContentBlock[]`; inline raster blocks carry canonical base64 plus `mimeType` and become durable attachments inside the runtime. The call queues the prompt, waits for its durable inbox receipt, and collects until the whole root agent next becomes idle. It returns `RunResult { sessionId, finalResponse, events, notifications }`; `events` is root-scoped, while notifications also contain discovered descendants.
 

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

@@ -26,7 +26,7 @@ const result = await harness.run('say hi')
 console.log(result.finalResponse)
 ```
 
-dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。服务器会在接受提示词前校验确切路由;省略推理强度时保留模型自身的默认值。握手失败会回收运行时,之后的调用可以用新进程重试,直至终结性的 `close()`。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
+dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。服务器会在接受提示词前校验确切路由;省略推理强度时保留模型自身的默认值。握手失败且清理成功时,实例会换入全新 client,后续调用使用新进程重试,直至终结性的 `close()`。如果初始化与 SDK 自有清理都失败,`start()` 会以 `AggregateError` 拒绝,其有序 errors 保留两个 cause,并继续保留失败的 client,而不会在尚未证明原进程退出时再 spawn 一个进程。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
 
 握手携带绝对 session workspace、provider/model、可选的 `reasoningEffort` 和可选的正整数 `maxTokens`。`run(input, { sessionId?, onNotification? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图片块携带规范 base64 与 `mimeType`,并在运行时内成为持久附件。该调用将 prompt 入队,等待持久 inbox 回执,并收集到整个根 agent 下次 idle。它返回 `RunResult { sessionId, finalResponse, events, notifications }`;`events` 仅限根 session,notification 还包括发现的后代。
 

+ 52 - 7
packages/sdk/client/src/api.ts

@@ -8,7 +8,7 @@
 
 import { randomUUID } from 'node:crypto'
 import { resolve } from 'node:path'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
 import { createProcessHarnessClient, HarnessClient, isRecord, SdkProtocolError } from './client.ts'
 import type { RuntimeProcessOptions } from './launch.ts'
 import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, RunResult, SdkPromptContentBlock } from './types.ts'
@@ -47,8 +47,9 @@ export class DeepSeekHarness implements AsyncDisposable {
 
   /**
    * The underlying JSON-RPC client (exposed for low-level access). A failed
-   * handshake reaps its runtime and swaps in a fresh instance, so do not
-   * cache this across a failed {@link start}.
+   * handshake swaps in a fresh instance only after cleanup proves the runtime
+   * exited; cleanup failure retains this client, so do not cache it across a
+   * failed {@link start}.
    * @returns the client currently owning the runtime subprocess.
    */
   get client(): HarnessClient {
@@ -57,9 +58,12 @@ export class DeepSeekHarness implements AsyncDisposable {
 
   /**
    * Start the subprocess and perform the `initialize` handshake once. On
-   * failure the runtime is reaped and a fresh client replaces it
-   * (`HarnessClient.close` is permanent), so a later call retries with a new
-   * subprocess — unless {@link close} already ended this harness.
+   * failure, successful SDK-owned cleanup reaps the runtime and installs a
+   * fresh client (`HarnessClient.close` is permanent), so a later call retries
+   * with a new subprocess unless {@link close} already ended this harness. If
+   * cleanup also fails, rejects with an `AggregateError` whose ordered errors
+   * preserve both causes and retains the failed client rather than spawning
+   * alongside a process whose exit was not proved.
    * @returns settlement of the (memoized) handshake.
    */
   start(): Promise<void> {
@@ -75,7 +79,14 @@ export class DeepSeekHarness implements AsyncDisposable {
         })
       } catch (error) {
         this.initialized = undefined
-        await this.clientInstance.close()
+        try {
+          await this.clientInstance.close()
+        } catch (cleanupError: unknown) {
+          throw new AggregateError(
+            [error, cleanupError],
+            'DeepSeek Harness initialization and cleanup failed',
+          )
+        }
         if (!this.closed) this.clientInstance = this.createClient()
         throw error
       }
@@ -222,6 +233,33 @@ export function normalizeInput(input: string | SdkPromptContentBlock[]): SdkProm
   return typeof input === 'string' ? [{ type: 'text', text: input }] : input
 }
 
+/** Validate the provider-read fields of one wire turn-end reason. */
+function validatedTurnEndReason(value: unknown): TurnEndReason {
+  if (!isRecord(value) || typeof value.kind !== 'string') {
+    throw new SdkProtocolError(`turn/end carried no reason envelope: ${JSON.stringify(value)}`)
+  }
+  if (value.kind === 'aborted') {
+    if (!isRecord(value.reason) || typeof value.reason.kind !== 'string') {
+      throw new SdkProtocolError(`turn/end carried a malformed aborted reason: ${JSON.stringify(value)}`)
+    }
+    switch (value.reason.kind) {
+      case 'user':
+      case 'parent':
+      case 'disposed':
+      case 'legacy':
+        break
+      case 'hook':
+        if (typeof value.reason.reason !== 'string') {
+          throw new SdkProtocolError(`turn/end carried a malformed hook abort reason: ${JSON.stringify(value)}`)
+        }
+        break
+      default:
+        throw new SdkProtocolError(`turn/end carried an unknown abort reason: ${JSON.stringify(value)}`)
+    }
+  }
+  return value as unknown as TurnEndReason
+}
+
 /** Validate the fields in a wire `session.event` envelope before returning the typed result. */
 function validatedSessionEvent(value: unknown): SessionEvent {
   if (!isRecord(value) || typeof value.type !== 'string') {
@@ -237,6 +275,13 @@ function validatedSessionEvent(value: unknown): SessionEvent {
       throw new SdkProtocolError(`assistant/message event carried malformed content: ${JSON.stringify(value)}`)
     }
   }
+  if (value.type === 'turn/end') {
+    const data = isRecord(value.data) ? value.data : undefined
+    if (data === undefined) {
+      throw new SdkProtocolError(`turn/end event carried malformed data: ${JSON.stringify(value)}`)
+    }
+    validatedTurnEndReason(data.reason)
+  }
   return value as unknown as SessionEvent
 }
 

+ 53 - 3
packages/sdk/client/tests/fake-runtime.ts

@@ -10,6 +10,7 @@
  * - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
  * - `FAKE_STATUS`: the `session.finished` status (default `ok`).
  * - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
+ * - `FAKE_ABORT_REASON_KIND`: nested cause for an `aborted` turn (default `user`).
  * - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
  * - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
  * - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
@@ -24,8 +25,10 @@
  * - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
  *   the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
  *   array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
- *   member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
- *   string (wire-validation probes).
+ *   member; `FAKE_MALFORMED_REASON`: the `turn/end` carries a bare reason
+ *   (`1`), an aborted reason without its cause (`aborted`), an unknown abort
+ *   cause (`abort-unknown`), a hook cause without its reason (`hook`), or no
+ *   data member (`no-data`) for wire-validation probes.
  * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
  *   assistant/message for a usage-only max-tokens step.
  * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
@@ -33,6 +36,8 @@
  *   arrives, then poll for the GO file before answering (deterministic
  *   cancel-during-handshake window).
  * - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
+ * - `FAKE_EXIT_DURING_PROMPT`: stream one partial chunk, then exit 17 while
+ *   the owned session run is waiting for its terminal state.
  * - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
  *   answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
  *   before the protocol failure (partial-output retention probe).
@@ -126,7 +131,31 @@ function runTurn(sessionId: string): void {
     },
   })
   const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
-  event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
+  if (reasonKind !== 'none') {
+    if (env.FAKE_MALFORMED_REASON === 'no-data') {
+      notify('session.event', { sessionId, event: { type: 'turn/end', seq: seq++, time: 0 } })
+      return
+    }
+    const reason = env.FAKE_MALFORMED_REASON === 'aborted'
+      ? { kind: 'aborted' }
+      : env.FAKE_MALFORMED_REASON === 'abort-unknown'
+        ? { kind: 'aborted', reason: { kind: 'future' } }
+        : env.FAKE_MALFORMED_REASON === 'hook'
+          ? { kind: 'aborted', reason: { kind: 'hook' } }
+          : env.FAKE_MALFORMED_REASON !== undefined
+            ? 'not-a-reason-envelope'
+            : reasonKind === 'aborted'
+              ? {
+                kind: 'aborted',
+                reason: env.FAKE_ABORT_REASON_KIND === 'hook'
+                  ? { kind: 'hook', reason: 'scripted hook abort' }
+                  : { kind: env.FAKE_ABORT_REASON_KIND ?? 'user' },
+              }
+              : reasonKind === 'error'
+                ? { kind: 'error', error: { message: 'scripted child error', code: 'UNKNOWN' } }
+                : { kind: reasonKind }
+    event(sessionId, 'turn/end', { turn: 0, reason })
+  }
   if (env.FAKE_SUBAGENT !== undefined) {
     const childId = `${sessionId}-child`
     notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
@@ -212,6 +241,27 @@ reader.on('line', (line) => {
         respond({})
         return
       }
+      if (env.FAKE_EXIT_DURING_PROMPT !== undefined) {
+        const partial = env.FAKE_TEXT ?? 'partial before exit'
+        respond({ messageId })
+        event(sessionId, 'assistant/chunk', {
+          turn: 0,
+          step: 0,
+          chunk: { type: 'text-delta', index: 0, text: partial },
+        })
+        event(sessionId, 'assistant/message', {
+          turn: 0,
+          step: 0,
+          message: {
+            id: `fake-partial-${seq}`,
+            role: 'assistant',
+            content: [{ type: 'text', text: partial }],
+            source: { kind: 'model', provider: 'fake', model: 'fake' },
+          },
+        })
+        setImmediate(() => { process.exit(17) })
+        return
+      }
       if (env.FAKE_HANG_PROMPT !== undefined) return
       if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
         respond({})

+ 55 - 1
packages/sdk/client/tests/sdk-client.spec.ts

@@ -9,7 +9,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import {
   DeepSeekHarness,
@@ -218,6 +218,48 @@ describe('DeepSeekHarness', () => {
     await expect(harness.run('later')).rejects.toThrow()
   })
 
+  it('preserves both initialize and SDK-owned cleanup failures', async () => {
+    const initializeError = new SdkProtocolError('malformed initialize')
+    const cleanupError = new Error('cleanup failed')
+    const start = vi.spyOn(HarnessClient.prototype, 'start').mockImplementation(() => {})
+    const initialize = vi.spyOn(HarnessClient.prototype, 'initialize').mockRejectedValue(initializeError)
+    const close = vi.spyOn(HarnessClient.prototype, 'close').mockRejectedValue(cleanupError)
+    try {
+      const harness = createProcessDeepSeekHarness(fakeLaunch())
+      const failedClient = harness.client
+      const failure = await harness.start().catch((error: unknown) => error)
+      expect(failure).toBeInstanceOf(AggregateError)
+      expect((failure as AggregateError).errors).toEqual([initializeError, cleanupError])
+      expect((failure as Error).message).toBe('DeepSeek Harness initialization and cleanup failed')
+      expect(harness.client).toBe(failedClient)
+    } finally {
+      start.mockRestore()
+      initialize.mockRestore()
+      close.mockRestore()
+    }
+  })
+
+  it('does not replace the client after terminal close wins a failed handshake', async () => {
+    let rejectInitialize!: (error: Error) => void
+    const initializeResult = new Promise<never>((_resolve, reject) => { rejectInitialize = reject })
+    const start = vi.spyOn(HarnessClient.prototype, 'start').mockImplementation(() => {})
+    const initialize = vi.spyOn(HarnessClient.prototype, 'initialize').mockReturnValue(initializeResult)
+    const close = vi.spyOn(HarnessClient.prototype, 'close').mockResolvedValue()
+    try {
+      const harness = createProcessDeepSeekHarness(fakeLaunch())
+      const original = harness.client
+      const pending = harness.start()
+      await harness.close()
+      rejectInitialize(new SdkProtocolError('late initialize failure'))
+      await expect(pending).rejects.toThrow('late initialize failure')
+      expect(harness.client).toBe(original)
+    } finally {
+      start.mockRestore()
+      initialize.mockRestore()
+      close.mockRestore()
+    }
+  })
+
   it('retries a failed handshake with a fresh runtime process', async () => {
     const dir = await tempDir('sdk-client-retry-')
     const marker = join(dir, 'first-boot-failed')
@@ -536,6 +578,18 @@ describe('wire payload validation', () => {
     await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
   })
 
+  it.each(['1', 'aborted', 'abort-unknown', 'hook', 'no-data'])('rejects malformed turn/end input %s as a protocol error', async (mode) => {
+    const harness = harnessWith({ FAKE_MALFORMED_REASON: mode })
+    await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
+  })
+
+  it('accepts the complete hook cancellation cause', async () => {
+    const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'hook' })
+    const result = await harness.run('hook-abort')
+    const end = result.events.findLast(event => event.type === 'turn/end')
+    expect(end?.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'hook', reason: 'scripted hook abort' } })
+  })
+
 })
 
 describe('stderr tail bound', () => {

+ 2 - 2
packages/session/session-projection-cache/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/session/session-projection-cache/README.md
-README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085
-README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482
+README.md: 3985daeca4dd3cc0b3a9b0fcf68070f368b0c31f
+README.zh.md: 19c494350b0f5a1dc5b7315f18ac5bf76eaee69e

+ 18 - 17
packages/session/session-projection-cache/README.md

@@ -2,42 +2,43 @@
 
 English | [中文](README.zh.md)
 
-The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
+The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one version-stamped document per session on the `session_projcache` storage domain in `per-record` layout (the shipped json backend lands each session's record at `<root>/session_projcache/sessions/<id>.json`). The cache never consults the persistence layer. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
 
 A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
 
-- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
+- **Reads and writes share one coherent state.** Every read is a synchronous lookup in the domain's in-memory tables; every write queues on the domain's per-unit write chain and mutates memory only after durability, so a read can never go around the write chain to the medium (no direct disk reads, no torn values).
+- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write self-heals. A crash between writes costs a longer tail replay, never a wrong value.
 - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
-- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log.
-- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
-- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
+- **A row must pass the live unit's `stateSchema`.** A malformed or stale record document reads as "no cache row" at open, so the cold path refolds from the log.
+- **Whole-record writes.** Each write atomically replaces the session's record document (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. The domain write chain serializes writes, so a newer cut never lands before an older one.
+- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live header is the witness) before accepting a record, so a deleted-then-recreated id cannot let an old record seed state folded from an unrelated log.
 - **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
+- **The medium is the domain's, private by default.** The json backend creates its tree owner-only (`0o700`); the cache does not depend on which persistence backend is mounted — no `locate`, no per-session-dir probing.
 
 ## Write policy
 
-Two mandatory points, throttled in between:
+Three mandatory points, throttled in between:
 
 | Trigger | Nature |
 |---|---|
-| `turn/end` | Mandatory — the turn-final value is what cold reads want. |
-| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
+| Session creation | Mandatory — the seed-derived cut (a forked child's inherited title, say) lands immediately, so a crash or a live-held session never loses it from the cold list. |
+| `turn/end` | Mandatory — the turn-final value is what listing reads want. |
+| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cache serves this session's final cut. |
 | `writeEveryEvents` committed events | Config throttle (count). |
 | `writeIntervalMs` since the first dirty event | Config throttle (interval). |
 
-Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
+Both throttle triggers are required `Config` fields (no defaults): the flush cadence is a deployment choice stated in cordis.yml.
 
 ## Listing read (`cachedSnapshot(meta)`)
 
-The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
-
-## Cold read (`coldSnapshot(id, signal?)`)
-
-The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
+A synchronous, zero-I/O read from the domain's in-memory tables: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, absent or foreign record document, or no usable rows); the api-proxy list carrier turns that into an absent column.
 
 `write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
 
 ## Composition
 
+The cache opens its domain through the storage stack, so base mounts `storage`, `storage-json` (root `dshHomePath('storages')`), and `storage-domain` (`backend: json`) before it:
+
 ```yaml
 - id: session-projection-cache
   name: '@deepseek-ai/dsh-session-projection-cache'
@@ -46,11 +47,11 @@ The read ladder, zero full-log load on the happy path: cached rows → `sessionP
     writeIntervalMs: 5000
 ```
 
-Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
+Injects `storageDomain`, `sessionProjections`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
 
 ## Model Experience
 
-None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
+None, as the cache only persists host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
 
 #### KV Cache effect
 
@@ -60,4 +61,4 @@ None; the cache never assembles or sends provider requests.
 
 - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
 - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
-- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.
+- **No cache-side cold refold** — the cache serves and refreshes its rows but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.

+ 18 - 17
packages/session/session-projection-cache/README.zh.md

@@ -2,42 +2,43 @@
 
 [English](README.md) | 中文
 
-持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。
+持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,在 `per-record` 布局的 `session_projcache` 存储域上为每个会话存一份带版本戳的文档(内置 json 后端把每个会话的记录落在 `<root>/session_projcache/sessions/<id>.json`)。缓存绝不咨询持久化层。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。
 
 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 
-- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
+- **读写共享同一份一致状态。** 每次读取都是对域内存表的同步查找;每次写入排进该域的单条写链,先落盘成功才改内存——读永远不会绕过写链去碰介质(没有直读磁盘、没有撕裂的值)。
+- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
-- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
-- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
-- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
+- **存储行必须通过当前单元的 `stateSchema`。** 畸形或过期的记录文档在打开时读作"无缓存行",冷路径从日志重新折叠。
+- **整记录写入。** 每次写入原子替换该会话的记录文档(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。域写链将写入串行化,新切面绝不会先于旧切面落盘。
+- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 为证验证它,再接受任何记录——被删后重建的 id 无法让旧记录播种来自无关日志的状态。
 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
+- **介质归域所有,默认私有。** json 后端以仅属主权限(`0o700`)创建自己的目录树;缓存不依赖挂载的是哪个持久化后端——没有 `locate`、没有每会话目录探测。
 
 ## 写策略
 
-两个必写点,其间节流:
+三个必写点,其间节流:
 
 | 触发 | 性质 |
 |---|---|
-| `turn/end` | 必写——冷读要的正是轮次终值。 |
-| 会话释放(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
+| 会话创建 | 必写——seed 派生的切面(比如 fork 子会话继承的标题)立即落盘,崩溃或长期存活的会话也不会从冷列表丢失。 |
+| `turn/end` | 必写——列表读要的正是轮次终值。 |
+| 会话释放(detach) | 必写——live 转 cold 的时刻;此后缓存服务该会话的最终切面。 |
 | 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
 | 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
 
-两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
+两个节流 `Config` 字段均必填(无默认值):写入节奏是部署选择,由 cordis.yml 明示。
 
 ## 列表读(`cachedSnapshot(meta)`)
 
-零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
-
-## 冷读(`coldSnapshot(id, signal?)`)
-
-读取阶梯,正常路径无需加载全量日志:缓存行 → `sessionProjections.restoreFloor`(锚定在最低可用水位之前一个事件的位置)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
+对域内存表的同步、零 I/O 读取:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、记录文档缺失或外来、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
 
 `write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
 
 ## 组合
 
+缓存通过存储栈打开自己的域,因此 base 先挂 `storage`、`storage-json`(根 `dshHomePath('storages')`)与 `storage-domain`(`backend: json`):
+
 ```yaml
 - id: session-projection-cache
   name: '@deepseek-ai/dsh-session-projection-cache'
@@ -46,11 +47,11 @@
     writeIntervalMs: 5000
 ```
 
-注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
+注入 `storageDomain`、`sessionProjections`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
 
 ## 模型体验
 
-无,因为缓存只持久化并恢复 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
+无,因为缓存只持久化 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
 
 #### KV Cache 影响
 
@@ -60,4 +61,4 @@
 
 - **不提供淘汰或保留接口**:记录会按会话持续累积;清理已存储的检查点属于带外维护,与会话持久化采用相同策略。
 - **间隔节流采用按会话的粗粒度控制**:一次无脏数据的写入完成后,计时器会在首个脏事件到达时启动;对于持续但未达到条数阈值的事件流,系统每个间隔写入一次,而不采用滑动窗口。
-- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。
+- **缓存侧不做冷重折叠**——缓存只服务并刷新自己的行,从不读取会话日志(不依赖持久化层);需要保证冷快照的消费方自行从日志重折叠。

+ 5 - 6
packages/session/session-projection-cache/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-session-projection-cache",
-  "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
+  "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -36,20 +36,19 @@
     "zod": "^4.4.3"
   },
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
-    "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-storage-domain": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-storage-json": "workspace:^"
   }
 }

+ 65 - 77
packages/session/session-projection-cache/src/index.ts

@@ -1,13 +1,17 @@
 /**
  * Persisted projection cache (`ctx.sessionProjectionCache`): durable
- * checkpoints of every client-visible or explicitly persisted projection unit's state, one record per
- * session on the domain data form (`session_projcache` domain — the shipped
- * json backend lands it beside `workspace.json`). The cache is a fold
- * shortcut, never an authority: a row is possibly stale (its `seq`
- * says how stale) but never wrong, so every write path is fail-soft (a lost
- * write costs a longer tail replay on the next cold read) and a
- * `ver` mismatch discards the row instead of migrating it. Design
- * authority: the session-projection RFC
+ * checkpoints of every projection unit's state, one record per session on
+ * the `session_projcache` domain (`per-record` layout — the shipped json
+ * backend stores one document per session under its root). Reads and writes
+ * share ONE coherent state: the domain's in-memory tables serve every read
+ * synchronously, and each write lands on the domain's write chain (durability
+ * first, then memory), so a read can never observe a disk write the memory
+ * has not applied, or a memory value the disk does not hold. The cache is a
+ * fold shortcut, never an authority: a row
+ * is possibly stale (its `seq` says how stale) but never wrong, so every
+ * write path is fail-soft (a lost write costs a longer tail replay on the
+ * next cold read) and a `ver` mismatch discards the row instead of migrating
+ * it. Design authority: the session-projection RFC
  * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
  * @module @deepseek-ai/dsh-session-projection-cache
  */
@@ -16,9 +20,6 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-// Empty type import: applies the package's cordis Context merge
-// (`ctx.sessionPersistence`), which this service reads on the cold path.
-import type {} from '@deepseek-ai/dsh-session-persistence'
 import type {
   ProjectionCheckpoint,
   ProjectionSnapshot,
@@ -40,8 +41,9 @@ declare module '@deepseek-ai/cordis' {
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -66,14 +68,14 @@ interface DirtyState {
 /**
  * The persisted projection cache service. Opens the `session_projcache`
  * domain at init, checkpoints live sessions on a throttled write-behind
- * (count/interval triggers from {@link Config}) plus two mandatory points —
- * `turn/end` and session disposal (the live-to-cold moment) — and serves the
- * cold-read ladder: cached row, persistence `readFrom` tail, registry
- * `restore`, durable write-back. Every durable write is fail-soft: failures
- * log a warning and the cache self-heals on the next write or cold read.
+ * (count/interval triggers from {@link Config}) plus three mandatory points —
+ * session creation, `turn/end`, and session disposal (the live-to-cold
+ * moment) — and serves the
+ * cached rows for a session header. Every durable write is fail-soft:
+ * failures log a warning and the cache self-heals on the next write.
  */
 export class SessionProjectionCache extends Service {
-  static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
+  static inject = ['storageDomain', 'sessionProjections', 'sessions']
 
   static Config: z<Config> = Config
 
@@ -97,7 +99,9 @@ export class SessionProjectionCache extends Service {
    * identity matches `expected`. A session id names a slot, not a lifecycle:
    * a recreated id or a persistence store swapped under a surviving cache
    * must not let an old record seed state folded from an unrelated log.
-   * Synchronous from the domain's in-memory state.
+   * Synchronous from the domain's in-memory state — the same state every
+   * write mutated, so a read can never go around the write chain to the
+   * medium.
    * @param id - the session whose record is read.
    * @param expected - the log identity the caller holds (live or stored header).
    * @returns the identity-matching record, or `undefined` (absent or unrelated).
@@ -110,12 +114,12 @@ export class SessionProjectionCache extends Service {
 
   /**
    * The zero-I/O listing read: whole values viewed straight from the stored
-   * rows (version-matching keys only), each cut carried with its watermark
-   * so a client value store can seed under its higher-seq-wins rule — as
-   * stale as the last durable checkpoint but never wrong, and never from an
+   * rows (version-matching keys only), each cut carried with its watermark so
+   * a client value store can seed under its higher-seq-wins rule — as stale
+   * as the last durable checkpoint but never wrong, and never from an
    * unrelated log (the caller's header is the identity witness). Fresher
-   * paths (the history tail baseline, {@link coldSnapshot}) supersede these
-   * values whenever a session is actually opened.
+   * paths (the history tail baseline) supersede these values whenever a
+   * session is actually opened.
    * @param meta - the listed session's header (identity witness; no log read).
    * @param keys - optional projection keys required by the caller's audience.
    * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -168,8 +172,9 @@ export class SessionProjectionCache extends Service {
   /**
    * Durably checkpoint one live session NOW (both mandatory points call
    * this; tests and carriers may too). The registry cut is snapshotted at
-   * this boundary (states are live references), then the whole record is
-   * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+   * this boundary (states are live references), then the session's record is
+   * replaced on the domain's write chain. NOT fail-soft — callers on the
+   * fail-soft paths contain it.
    * @param session - the live session to checkpoint.
    * @returns resolution after durability and event emission.
    */
@@ -188,49 +193,28 @@ export class SessionProjectionCache extends Service {
   }
 
   /**
-   * Cold-read one persisted session's projections with zero full-log load:
-   * cached rows + a persistence `readFrom` tail from the registry's restore
-   * floor, refolded by the registry and written back (fail-soft) so the next
-   * cold read starts closer. A cache row invalidated by a shrunk log
-   * (crash-repair truncation) triggers one full re-read from seq 0 — the
-   * ladder's slow rung, still no crash. Rejects when the session has no
-   * persisted log (`not found` from the persistence seam).
-   * @param id - the persisted session to read.
-   * @param signal - optional cancellation for the persistence reads.
-   * @returns the snapshot cut at the stored log end.
+   * Cold-read one session's projections from its complete log. Each unit is
+   * seeded from the identity-checked cached rows — the registry skips `apply`
+   * for the already-folded prefix (events at or below the row's `seq`) — and
+   * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+   * the first cold read creates the cache row and later ones seed from it.
+   * The caller supplies the complete log in seq order: this service never
+   * consults the persistence layer.
+   * @param meta - the stored session header (identity witness).
+   * @param events - the session's complete log, in seq order.
+   * @returns the projection cut at the log end.
    */
-  async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
-    const record = this.requireTable().get(id)
-    const cached = record?.rows ?? {}
-    const floor = this.ctx.sessionProjections.restoreFloor(cached)
-    const persistence = this.ctx.sessionPersistence
-    if (floor === undefined) {
-      // No unit registered: nothing to fold, but the not-found contract must
-      // hold in this topology too — the probe read rejects for an absent log
-      // and dates the empty cut for a present one.
-      const probe = await persistence.readFrom(id, 0, signal)
-      return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
-    }
-    let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
-    const tail = await persistence.readFrom(id, floor, signal)
-    // The tail's stored header is the identity witness: a record bound to a
-    // different lifecycle (recreated id, swapped store) is discarded whole
-    // before any of its rows can seed a fold.
-    const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
-    try {
-      if (!related) throw new Error('unrelated log identity')
-      restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, tail.meta)
-    } catch {
-      // Recoverable failures are an unrelated record, a row outside the
-      // supplied suffix or log end, and stateSchema rejection. The full read
-      // removes every checkpoint seed and lets each unit refold from init.
-      const whole = await persistence.readFrom(id, 0, signal)
-      restored = this.ctx.sessionProjections.restore({}, whole.events, 0, whole.meta)
-    }
-    await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
+  coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot {
+    const restored = this.ctx.sessionProjections.restore(this.recordFor(meta.id, identityOf(meta))?.rows ?? {}, events, 0, meta)
+    // Refresh the row so the next cold read seeds from it; fail-soft and
+    // fire-and-forget — a failed write-back only costs a longer tail replay.
+    void this.put(meta.id, identityOf(meta), restored.checkpoint).catch((error: unknown) => {
+      this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`)
+    })
     return restored.snapshot
   }
 
+
   // --- write-behind (throttle + mandatory points) ---
 
   private installWritePath(): void {
@@ -254,6 +238,15 @@ export class SessionProjectionCache extends Service {
       }, this.config.writeIntervalMs)
     })
 
+    // Creation is the FIRST mandatory point: a session that never talks (a
+    // forked child seeded with its ancestor's title, say) would otherwise
+    // get its first row only at detach — so a crash, or a fork held live in
+    // the store, would leave the seed-derived values (the title) unreadable
+    // on the cold list. The creation write captures the seed-derived cut.
+    this.ctx.on('session/created', (session: Session) => {
+      void this.flushSoft(session, 'create')
+    })
+
     // Detach (the live-to-cold moment): the second mandatory point. After
     // this write the cold-read ladder serves the session from the cache.
     // flushSoft's synchronous prefix reads and resets the dirty state, so
@@ -264,7 +257,11 @@ export class SessionProjectionCache extends Service {
       this.dirty.delete(session)
     })
 
-    // Clear pending timers with the plugin (their sessions outlive the cache).
+    // With the plugin (their sessions outlive the cache): clear pending
+    // timers and stop accepting new work. The domain-close effect registered
+    // in init runs after this disposer and drains already-queued writes, so
+    // a late flush can never land after disposal (it rejects `closed` into
+    // flushSoft's warning instead).
     this.ctx.effect(() => () => {
       for (const state of this.dirty.values()) {
         if (state.timer !== undefined) clearTimeout(state.timer)
@@ -276,7 +273,7 @@ export class SessionProjectionCache extends Service {
   /**
    * One fail-soft durable checkpoint. Every caller has work by construction:
    * the throttle triggers only fire dirty (markClean clears the timer with
-   * the counter) and the two mandatory points write unconditionally.
+   * the counter) and the mandatory points write unconditionally.
    */
   private async flushSoft(session: Session, trigger: string): Promise<void> {
     try {
@@ -306,15 +303,6 @@ export class SessionProjectionCache extends Service {
     await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
   }
 
-  /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
-  private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise<void> {
-    try {
-      await this.put(id, identity, rows)
-    } catch (error) {
-      this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
-    }
-  }
-
   private requireTable(): KvTable<SessionId, CheckpointRecord> {
     /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
     if (this.table === undefined) throw new Error('session projection cache is not initialized')

+ 2 - 2
packages/session/session-projection-cache/src/invariant.ts

@@ -19,8 +19,8 @@ export const inject = ['invariants']
  * the registry fold at its `seq` watermark) is only checkable by re-running the
  * fold over the persisted log — duplicating the implementation rather than
  * detecting drift — and its staleness is by design (fail-soft writes). The
- * durable boundary is already schema-validated by the storage-domain layer
- * on every reopen, and the read ladder's version/watermark guards are proven
+ * durable boundary is schema-validated by the cache's own zod parse on every
+ * read, and the read ladder's version/watermark guards are proven
  * by the package spec.
  */
 const install: InvariantInstaller = () => {}

+ 17 - 13
packages/session/session-projection-cache/src/spec.ts

@@ -1,11 +1,12 @@
 /**
- * The session-projcache domain declaration: one `sessions` table keyed by
+ * The projection-cache domain declaration: one `sessions` table keyed by
  * {@link SessionId}, each record the full projection checkpoint for one
- * session (`key → {ver, seq, val}` rows). The spec object
- * is the single source of the domain's identity, version, and record schema;
- * the storage-domain routing decides the medium (the shipped composition's
- * json backend lands it at `<root>/session_projcache.json`, beside
- * `workspace.json`).
+ * session (`key → {ver, seq, val}` rows). The spec object is the single
+ * source of the domain's identity, version, layout, and record schema; the
+ * storage-domain routing decides the medium (the shipped composition's json
+ * backend stores the domain `per-record`: one document per session under
+ * `<root>/session_projcache/sessions/`, so a checkpoint write rewrites one
+ * session's document instead of the whole unit).
  * @module @deepseek-ai/dsh-session-projection-cache/src/spec
  */
 
@@ -32,9 +33,9 @@ export const checkpointRow = z.object({
  * that distinguish one session lifecycle from another under the same id. A
  * session id names a slot, not a lifecycle — a deleted-then-recreated id, or
  * a persistence root swapped under a surviving cache, would otherwise let an
- * old row pass every watermark check and seed state folded from an unrelated
- * log. Reads validate this against the live header (listing) or the stored
- * header (cold read) before accepting any row.
+ * old record pass every watermark check and seed state folded from an
+ * unrelated log. Reads validate this against the live header (listing) or
+ * the stored header (cold read) before accepting any record.
  */
 export const checkpointIdentity = z.object({
   createdAt: z.number().int().nonnegative(),
@@ -59,12 +60,15 @@ export const checkpointRecord = z.object({
 export type CheckpointRecord = z.infer<typeof checkpointRecord>
 
 /**
- * The session-projcache domain spec. Version bumps discard the whole medium
- * (cache semantics: a stale or unreadable cache costs a longer tail replay,
- * never a wrong value).
+ * The session-projcache domain spec. The `per-record` layout scopes version
+ * bumps per session: after a bump, a stale session document is discarded on
+ * open (cache semantics — a stale or unreadable cache costs a longer tail
+ * replay, never a wrong value) while the rest of the domain stays usable,
+ * instead of rejecting the whole medium.
  */
 export const projectionCacheDomainSpec = defineDomain({
   name: 'session_projcache',
-  version: 3,
+  version: 4,
+  layout: 'per-record',
   tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
 })

+ 256 - 230
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -2,26 +2,40 @@
  * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
  * count/interval throttling between them, fail-soft durability (a failed
  * write logs and stays stale, never throws into the event path), and the
- * cold-read ladder (cached row + readFrom tail + registry restore +
- * write-back; version bump and shrunk-log rows degrade to a full re-read).
+ * synchronous cached listing read. The durable medium is the
+ * `session_projcache` storage domain in per-record layout: one
+ * version-stamped document per session under the json backend root at
+ * `<root>/session_projcache/sessions/<id>.json`. Reads never touch the
+ * medium — they come from the domain's in-memory tables, which writes mutate
+ * only after durability.
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
-import Storage from '@deepseek-ai/dsh-storage'
-import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
 import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
+import Storage from '@deepseek-ai/dsh-storage'
+import {
+  apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
+} from '@deepseek-ai/dsh-storage-json'
+import {
+  apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
+} from '@deepseek-ai/dsh-storage-domain'
 import SessionProjectionCache from '../src/index.ts'
+import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts'
+import type { CheckpointRecord } from '../src/spec.ts'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
     'cache-test/marks': MarksState
     'cache-test/marks2': Map<string, string>
+    'cache-test/count': number
   }
   interface SessionProjectionMap {
     'cache-test/marks': { marks: string[] }
@@ -51,49 +65,38 @@ const marksUnit = (stateVersion = 1) => ({
   stateVersion,
 }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
 
-/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
-function fakePersistence(logs: Map<string, SessionEvent[]>) {
-  const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
-    const events = logs.get(String(id))
-    if (events === undefined) throw new Error(`session "${id}" not found`)
-    return {
-      meta: { version: 0, id, createdAt: 0 },
-      events: events.filter(event => event.seq >= fromSeq),
-    }
-  })
-  return { readFrom }
-}
+/** One session's record document on the per-record medium. */
+const recordPath = (root: string, id: Session['id']): string =>
+  join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`)
 
-/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
+/** Header shape for cachedSnapshot calls. */
 const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
   ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
 
 interface HarnessOptions {
-  pool?: MemoryMediaPool
+  root?: string
   config?: { writeEveryEvents: number; writeIntervalMs: number }
   stateVersion?: number
-  logs?: Map<string, SessionEvent[]>
 }
 
 const contexts: Context[] = []
+const roots: string[] = []
 
 async function harness(options: HarnessOptions = {}) {
-  const pool = options.pool ?? new MemoryMediaPool()
-  const logs = options.logs ?? new Map<string, SessionEvent[]>()
+  const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+  roots.push(root)
   const ctx = new Context()
   contexts.push(ctx)
+  // The cache opens its domain through the storage stack; the json backend
+  // lands the per-record tree under this tmp root.
   await ctx.plugin(Storage)
-  ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-  const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-  ctx.storage.mount('domain', facility)
-  ctx.provide('storageDomain', facility)
+  await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+  await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
   ctx.sessionProjections.register(marksUnit(options.stateVersion))
-  const persistence = fakePersistence(logs)
-  ctx.provide('sessionPersistence', persistence as never)
   const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-  return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
+  return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
 }
 
 const mark = (session: Session, marks: string[]): SessionEvent =>
@@ -102,42 +105,72 @@ const mark = (session: Session, marks: string[]): SessionEvent =>
 const endTurn = (session: Session): SessionEvent =>
   session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
 
-/** The stored medium record for one session id (undefined = never written). */
-function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
-  return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
-    {
-      identity: { createdAt: number; cwd?: string }
-      rows: Record<string, { ver: number; seq: number; val: unknown }>
-    } | undefined
+/** The stored record for one session id (undefined = absent or unreadable). */
+async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
+  try {
+    const document = JSON.parse(await readFile(recordPath(root, id), 'utf8')) as { record: unknown }
+    return checkpointRecord.parse(document.record)
+  } catch {
+    return undefined
+  }
 }
 
-/** The stored medium rows for one session id (undefined = never written). */
-function storedRows(pool: MemoryMediaPool, id: Session['id']) {
-  return storedRecord(pool, id)?.rows
+/** The stored rows for one session id (undefined = absent or unreadable). */
+async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
+  return (await storedRecord(root, id))?.rows
 }
 
-/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
-const settle = () => new Promise(resolve => setTimeout(resolve, 0))
+/** Pre-seed one session's record document with a stored checkpoint record. */
+async function seedRecord(
+  root: string,
+  id: string,
+  rows: CheckpointRecord['rows'],
+  identity: CheckpointRecord['identity'] = { createdAt: 0 },
+): Promise<void> {
+  const path = recordPath(root, SessionId(id))
+  await mkdir(dirname(path), { recursive: true })
+  await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version, record: { identity, rows } }))
+}
+
+/** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */
+const settle = () => new Promise(resolve => setTimeout(resolve, 40))
 
 afterEach(async () => {
   vi.useRealTimers()
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
 })
 
 describe('SessionProjectionCache write policy', () => {
   it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     const session = ctx.sessions.create(SessionId('turn-end'))
     mark(session, ['a'])
-    expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet
+    // Creation already wrote the init cut; the mark is throttled, so the
+    // stored row is still the creation-time cut (no marks folded).
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
     const end = endTurn(session)
     await settle()
-    const rows = storedRows(pool, session.id)
+    const rows = await storedRows(root, session.id)
     expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
   })
 
+  it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
+    const { ctx, root } = await harness()
+    // A forked child seeded with its ancestor's title-like event: no
+    // conversation follows, yet the creation write must capture the fold so
+    // a crash or a live-held fork still lists the derived value.
+    const session = ctx.sessions.create(SessionId('seeded'), {
+      seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
+    })
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
+      .toEqual({ marks: ['seed'] })
+  })
+
   it('writes at session disposal (detach, the live-to-cold moment)', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     // Sessions dispose with their owning fiber: create in a child plugin.
     let session: Session | undefined
     const owner = await ctx.plugin(Object.assign((inner: Context) => {
@@ -147,39 +180,37 @@ describe('SessionProjectionCache write policy', () => {
     mark(session, ['live'])
     await owner.dispose()
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
   })
 
   it('flushes when the in-turn event count reaches the configured threshold', async () => {
-    const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
+    const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
     const session = ctx.sessions.create(SessionId('count'))
     mark(session, ['1'])
     mark(session, ['2'])
     await settle()
-    expect(storedRows(pool, session.id)).toBeUndefined()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
     mark(session, ['3'])
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
   })
 
   it('flushes on the configured interval when the count threshold is not reached', async () => {
-    vi.useFakeTimers()
-    const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } })
+    const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
     const session = ctx.sessions.create(SessionId('interval'))
     mark(session, ['slow'])
-    await vi.advanceTimersByTimeAsync(249)
-    expect(storedRows(pool, session.id)).toBeUndefined()
-    await vi.advanceTimersByTimeAsync(1)
-    await vi.advanceTimersByTimeAsync(0)
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
+    await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
+    await settle() // past the interval; the fire-and-forget write lands
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
   })
 
   it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     // Never dirtied: no events — write() still lands the init-derived cut.
     const clean = ctx.sessions.create(SessionId('clean-write'))
     await ctx.sessionProjectionCache.write(clean)
-    expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
+    expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
     // A unit whose state violates the plain-JSON contract fails the write loud.
     ctx.sessionProjections.register({
       key: 'cache-test/marks2',
@@ -193,7 +224,7 @@ describe('SessionProjectionCache write policy', () => {
 
   it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
     vi.useFakeTimers()
-    const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
+    const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
     const armed = ctx.sessions.create(SessionId('armed'))
     const cleaned = ctx.sessions.create(SessionId('cleaned'))
     mark(armed, ['pending']) // timer armed, no write yet
@@ -203,28 +234,105 @@ describe('SessionProjectionCache write policy', () => {
     await fiber.dispose()
     // The armed timer died with the plugin: advancing time writes nothing.
     await vi.advanceTimersByTimeAsync(10_000)
-    expect(storedRows(pool, armed.id)).toBeUndefined()
+    // Only the creation cut exists: the armed mark never wrote.
+    expect((await storedRows(root, armed.id))?.['cache-test/marks']?.seq).toBe(-1)
   })
 
   it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
-    const { ctx, pool } = await harness()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Storage)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(marksUnit())
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    // A directory where the record document must land makes the atomic
+    // rename fail — including the creation write, so no row ever lands.
+    const blocker = recordPath(root, SessionId('fail-soft'))
+    await mkdir(blocker, { recursive: true })
     const session = ctx.sessions.create(SessionId('fail-soft'))
     mark(session, ['x'])
-    pool.failNextWrites = 1
     endTurn(session)
     await settle()
-    expect(storedRows(pool, session.id)).toBeUndefined()
+    expect(await storedRows(root, session.id)).toBeUndefined()
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
-    // Self-heal: the next mandatory point writes the current cut.
+    // Self-heal: once the blocker clears, the next mandatory point writes.
+    await rm(recordPath(root, session.id), { recursive: true })
     mark(session, ['y'])
     endTurn(session)
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
   })
 })
 
-describe('SessionProjectionCache cold read', () => {
+describe('SessionProjectionCache listing read', () => {
+  it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
+    const { cache } = await harness({ root })
+    const id = SessionId('listed')
+    // Matching header: values plus the watermark the client seeds under.
+    expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
+    // A recreated id (different createdAt): the record is unrelated — no block.
+    expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
+    // Unknown id: no block.
+    expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
+  })
+
+  it('returns undefined when the stored record is version-mismatched', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A stale version-stamped document is discarded at open: absent record.
+    const path = recordPath(root, SessionId('all-stale'))
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, JSON.stringify({
+      version: projectionCacheDomainSpec.version + 1,
+      record: { identity: { createdAt: 0 }, rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } } },
+    }))
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
+  })
+
+  it('returns undefined when every stored row is version-mismatched', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A current document whose rows all fail the live unit's stateVersion:
+    // the listing view is empty, so no block is served.
+    await seedRecord(root, 'row-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')))).toBeUndefined()
+  })
+
+  it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' })
+    const { cache } = await harness({ root })
+    const id = SessionId('homed')
+    expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
+    expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
+    expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
+  })
+
+  it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    const path = recordPath(root, SessionId('malformed'))
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, 'not json at all')
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
+  })
+})
+
+describe('SessionProjectionCache cold-read seeding', () => {
+  /** One session's event log: turn/start, one mark per group, turn/end. */
   const storedLog = (marks: string[][]): SessionEvent[] => {
     const events: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
@@ -236,186 +344,104 @@ describe('SessionProjectionCache cold read', () => {
     return events
   }
 
-  /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */
-  function seedRow(
-    pool: MemoryMediaPool,
-    id: string,
-    row: { ver: number; seq: number; val: unknown },
-    identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
-  ): void {
-    pool.versions.set('session_projcache', 3)
-    pool.media.set('session_projcache', {
-      tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
-      global: null,
+  it('hydratePrepared seeds from a matching row and retries from the exact log on a malformed one', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // Records land on disk before the domain opens, so the in-memory table
+    // picks them up at init.
+    await seedRecord(root, 'prepared-seeded', {
+      'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['cached'] } },
     })
-  }
-
-  it('retries prepared hydration without a malformed cached checkpoint', async () => {
-    const pool = new MemoryMediaPool()
-    const id = SessionId('prepared-cache-fallback')
-    seedRow(pool, id, { ver: 1, seq: 1, val: { marks: 'malformed' } })
+    await seedRecord(root, 'prepared-fallback', {
+      'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'malformed' } },
+    })
+    const { cache } = await harness({ root })
     const events = storedLog([['fresh']])
-    const { cache } = await harness({ pool })
-    const meta = headerOf(id)
-    const session = Session.create(id, events, meta)
 
-    expect(cache.hydratePrepared(session, meta, events)).toEqual({
+    // A matching row hydrates the prepared Session without a persistence read.
+    const seeded = headerOf(SessionId('prepared-seeded'))
+    const seededSession = Session.create(seeded.id, events, seeded)
+    expect(cache.hydratePrepared(seededSession, seeded, events)).toEqual({
       asOfSeq: 2,
-      values: { 'cache-test/marks': { marks: ['fresh'] } },
+      values: { 'cache-test/marks': { marks: ['cached'] } },
     })
-  })
 
-  it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
-    // A warm-era checkpoint at watermark 1 (only ['a'] folded).
-    seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
-    const { cache, persistence, pool: samePool } = await harness({ pool, logs })
-    const id = SessionId('cold')
-    const snapshot = await cache.coldSnapshot(id)
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
-    expect(snapshot.asOfSeq).toBe(3)
-    // The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
-    expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
-    // Write-back: the stored row advanced to the served cut.
-    expect(storedRows(samePool, id)?.['cache-test/marks'])
-      .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
-  })
-
-  it('discards a version-mismatched row and refolds the full log', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['bumped', storedLog([['a']])]])
-    seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
-    const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
-    const snapshot = await cache.coldSnapshot(SessionId('bumped'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    // Mismatch pulls the floor to 0: one full read, no second pass needed.
-    expect(persistence.readFrom).toHaveBeenCalledTimes(1)
-    expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
-  })
-
-  it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
-    seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
-    const { cache, persistence } = await harness({ pool, logs })
-    const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    expect(snapshot.asOfSeq).toBe(2)
-    // Anchored tail read (floor 9) came back empty -> full re-read from 0.
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
-  })
-
-  it('discards malformed persisted state and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['malformed', storedLog([['real']])]])
-    seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
-    const { cache, persistence } = await harness({ pool, logs })
-
-    const snapshot = await cache.coldSnapshot(SessionId('malformed'))
-
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
-  })
-
-  it('write-back failure is contained: the snapshot is still served', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['soft', storedLog([['a']])]])
-    const { ctx, cache } = await harness({ pool, logs })
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    pool.failNextWrites = 1
-    const snapshot = await cache.coldSnapshot(SessionId('soft'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
-  })
-
-  it('rejects for a session with no persisted log', async () => {
-    const { cache } = await harness()
-    await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
-  })
-
-  it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
-    // A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
-    // its rows pass every watermark check, but the identity does not match.
-    seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
-    const { cache, pool: samePool } = await harness({ pool, logs })
-    const snapshot = await cache.coldSnapshot(SessionId('reborn'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
-    // The write-back rebinds the record to the actual log's identity.
-    expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 })
-  })
-
-  it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
-    const { cache } = await harness({ pool })
-    expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
-  })
-
-  it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
-    const { cache } = await harness({ pool })
-    const id = SessionId('homed')
-    expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
-    expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
-    expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
-  })
+    // A malformed row cannot seed the fold; hydration falls back to the
+    // exact log so a valid Session stays readable.
+    const fallback = headerOf(SessionId('prepared-fallback'))
+    const fallbackSession = Session.create(fallback.id, events, fallback)
+    expect(cache.hydratePrepared(fallbackSession, fallback, events)).toEqual({
+      asOfSeq: 2,
+      values: { 'cache-test/marks': { marks: ['fresh'] } },
+    })
 
-  it('dates an empty stored log at -1 in the zero-units topology', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['empty', [] as SessionEvent[]]])
-    const ctx = new Context()
-    contexts.push(ctx)
-    await ctx.plugin(Storage)
-    ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-    ctx.storage.mount('domain', facility)
-    ctx.provide('storageDomain', facility)
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    ctx.provide('sessionPersistence', fakePersistence(logs) as never)
-    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
-      .resolves.toEqual({ asOfSeq: -1, values: {} })
+    // No row at all: hydrate from init over the exact log.
+    const bare = headerOf(SessionId('prepared-bare'))
+    const bareSession = Session.create(bare.id, events, bare)
+    expect(cache.hydratePrepared(bareSession, bare, events)).toEqual({
+      asOfSeq: 2,
+      values: { 'cache-test/marks': { marks: ['fresh'] } },
+    })
   })
 
-  it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
-    const { cache } = await harness({ pool })
-    const id = SessionId('listed')
-    // Matching header: values plus the watermark the client seeds under.
-    expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
-    // A recreated id (different createdAt): the record is unrelated — no block.
-    expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
-    // Unknown id: no block.
-    expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
+  it('coldSnapshot traverses the full log but applies only the events after each cached watermark', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A cached row covering the prefix through seq 2 (three applies folded).
+    await seedRecord(root, 'cold-snap', {
+      'cache-test/count': { ver: 1, seq: 2, val: 3 },
+    }, { createdAt: 9 })
+    const { cache, ctx } = await harness({ root })
+    const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
+    ctx.sessionProjections.register({
+      key: 'cache-test/count',
+      stateSchema: z.number().int().nonnegative(),
+      init: () => 0,
+      apply,
+      stateVersion: 1,
+    } satisfies ProjectionDefinition<'cache-test/count', number>)
+    const meta = headerOf(SessionId('cold-snap'), 9)
+    const events = Array.from({ length: 5 }, (_, seq) => ({
+      type: 'cache-test/mark', seq, time: seq, data: { marks: [`m${seq}`] },
+    })) as SessionEvent[]
+    const snapshot = cache.coldSnapshot(meta, events)
+    // The full log was traversed, but the fold applied only seqs 3 and 4.
+    expect(apply).toHaveBeenCalledTimes(2)
+    expect(apply.mock.calls.map(call => call[1].seq)).toEqual([3, 4])
+    expect(snapshot.asOfSeq).toBe(4)
+    // Host-only unit: folded but not served; the refreshed row is written
+    // back (fail-soft, fire-and-forget) once the write lands.
+    expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
+    await settle()
+    expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
+    // No cached row yet: the first cold read folds from init over the full
+    // log and creates the cache row (the `?? {}` seed path).
+    const fresh = headerOf(SessionId('cold-fresh'), 10)
+    cache.coldSnapshot(fresh, events)
+    expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
+    await settle()
+    expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
   })
 
-  it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
-    // Same composition minus any registered unit: restoreFloor is undefined,
-    // yet coldSnapshot must still reject for an absent log (probe read) and
-    // serve an empty cut at the stored end for a present one.
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
+  it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     const ctx = new Context()
     contexts.push(ctx)
     await ctx.plugin(Storage)
-    ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-    ctx.storage.mount('domain', facility)
-    ctx.provide('storageDomain', facility)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
-    ctx.provide('sessionPersistence', fakePersistence(logs) as never)
+    ctx.sessionProjections.register(marksUnit())
     await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
-      .resolves.toEqual({ asOfSeq: 2, values: {} })
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    // A directory where the record document must land makes the write-back
+    // fail; the cold read itself still succeeds and never throws.
+    const meta = headerOf(SessionId('cold-fail'))
+    await mkdir(recordPath(root, meta.id), { recursive: true })
+    expect(ctx.sessionProjectionCache.coldSnapshot(meta, [])).toBeDefined()
+    await settle()
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed'))
   })
 })

+ 7 - 7
packages/session/session-projection-cache/tsconfig.json

@@ -9,10 +9,10 @@
   ],
   "references": [
     {
-      "path": "../../../vendor/cosmokit"
+      "path": "../../../vendor/cordis"
     },
     {
-      "path": "../../../vendor/cordis"
+      "path": "../../../vendor/cosmokit"
     },
     {
       "path": "../../../vendor/schemastery"
@@ -21,10 +21,7 @@
       "path": "../../core/session"
     },
     {
-      "path": "../session-persistence"
-    },
-    {
-      "path": "../session-projection"
+      "path": "../../runtime-diagnostics/invariants"
     },
     {
       "path": "../../storage/storage"
@@ -33,7 +30,10 @@
       "path": "../../storage/storage-domain"
     },
     {
-      "path": "../../runtime-diagnostics/invariants"
+      "path": "../../storage/storage-json"
+    },
+    {
+      "path": "../session-projection"
     }
   ]
 }

+ 4 - 1
packages/shell/tool-pwsh/tests/loader.spec.ts

@@ -46,6 +46,9 @@ describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => {
       libBinScript: driver,
       configPath,
       tsconfigPath: repoTsconfig,
+      // The self-hosted Windows pool can take roughly 40 seconds to boot this
+      // real Loader composition under the full CI load.
+      processTimeoutMs: 90_000,
       inspect: async (cwd) => {
         report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport
       },
@@ -59,5 +62,5 @@ describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => {
     expect(report?.foregroundText).toBe('loader-ok\n')
     expect(report?.backgroundText).toContain('loader-bg-ok')
     expect(report?.backgroundText).toContain('[status: completed, exit code: 0]')
-  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS + 75_000)
 })

+ 17 - 0
packages/storage/storage-domain/src/spec.ts

@@ -37,6 +37,14 @@ export interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
@@ -83,6 +91,14 @@ export function defineDomain<S extends DomainSpec>(spec: S): S {
   if (!Number.isInteger(spec.version) || spec.version < 0) {
     throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)
   }
+  if (spec.layout !== undefined) {
+    // Runtime boundary: the union type is compile-time only — a spec built
+    // from config could carry any value, and a bad one must fail loud here.
+    const layout: string = spec.layout
+    if (layout !== 'single' && layout !== 'per-record') {
+      throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`)
+    }
+  }
   for (const table of Object.keys(spec.tables)) {
     if (!UNIT_NAME_RE.test(table)) {
       throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
@@ -108,5 +124,6 @@ export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {
     version: spec.version,
     tables: Object.keys(spec.tables),
     hasGlobal: spec.global !== undefined,
+    ...spec.layout === undefined ? {} : { layout: spec.layout },
   }
 }

+ 12 - 1
packages/storage/storage-domain/tests/domain.spec.ts

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
-import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts'
+import { apply, defineDomain, descriptorOf, DomainFacility, domainTable } from '../src/index.ts'
 import type { Config } from '../src/index.ts'
 import type { DomainChanged } from '../src/events.ts'
 import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts'
@@ -57,6 +57,17 @@ describe('defineDomain', () => {
       tables: {},
     })).toThrow(/must not accept null/)
   })
+
+  it('rejects an invalid layout and projects the declared one onto the descriptor', () => {
+    // A spec built from config can carry any value; the union type is
+    // compile-time only, so the runtime boundary check must reject it.
+    expect(() => defineDomain({ name: 'ok', version: 1, layout: 'every-record' as 'single', tables: {} }))
+      .toThrow(/layout/)
+    expect(descriptorOf(defineDomain({ name: 'per', version: 1, layout: 'per-record', tables: {} })))
+      .toMatchObject({ name: 'per', layout: 'per-record' })
+    // The default (single) layout is absent from the descriptor.
+    expect(descriptorOf(spec)).not.toHaveProperty('layout')
+  })
 })
 
 describe('DomainFacility.open', () => {

+ 2 - 2
packages/storage/storage-json/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/storage/storage-json/README.md
-README.md: 3a079da3f516af4a3f7bc13dcf0115589fa42ff3
-README.zh.md: f9b2ffb7444893a27a2da1d3dcd324c73452cc92
+README.md: d23362be7ad9d884a60b941663dfdd33668c300d
+README.zh.md: b90c78fa41302ce5f14a897b07cf1eb548e1843b

+ 9 - 3
packages/storage/storage-json/README.md

@@ -2,12 +2,18 @@
 
 English | [中文](README.zh.md)
 
-JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
+JSON backend for the [storage hub](../storage/README.md), registered as backend `json`, serving two unit layouts:
+
+- **`single` (default)** — one human-readable `<unit>.json` file per unit under a configured root.
+- **`per-record`** — one version-stamped document per record at `<root>/<unit>/<table>/<key>.json` (plus `global.json`), so one write replaces one record instead of the whole unit; the unit is stateless (the directory is the state; `loadAll` re-reads the tree). Record keys must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects.
+
+Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
 
 ## Model
 
-- The in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
-- A missing file opens as an empty unit and materializes on the first write. A foreign or unparsable file rejects with `malformed-medium`; a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance).
+- `single` layout: the in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
+- A missing file (or unit directory, for `per-record`) opens as an empty unit and materializes on the first write. In `single` layout a foreign or unparsable file rejects with `malformed-medium`, and a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance). In `per-record` layout the contract is per record instead: a document that is malformed or stamped with another version reads as an absent record, so one bad or stale file never bricks the unit, and a version bump discards stale records rather than rejecting the whole unit.
+- An empty `per-record` tree bootstraps its declared-table records from a legacy `<unit>.json` whole-unit file and retains that file unchanged. Any new-layout document path in a declared table, or `global.json` for a declared global, suppresses the bootstrap for the whole unit even when the document is unreadable or stale; absent records remain absent instead of being filled from legacy state.
 - Write ordering across calls belongs to the caller (the domain layer's write chain); each single call is atomic and durable once resolved.
 
 ## Config

+ 9 - 3
packages/storage/storage-json/README.zh.md

@@ -2,12 +2,18 @@
 
 [English](README.md) | 中文
 
-[存储中心](../storage/README.zh.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
+[存储中心](../storage/README.zh.md)的 JSON 后端,注册为后端 `json`,提供两种单元布局:
+
+- **`single`(默认)**——配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件。
+- **`per-record`**——每个记录一个带版本戳的文档,位于 `<root>/<unit>/<table>/<key>.json`(外加 `global.json`),一次写入只替换一条记录而不是整个单元;该单元无状态(目录即状态,`loadAll` 重扫目录树)。记录键必须路径安全(`[a-zA-Z0-9_-]+`);不安全键会被拒绝。
+
+设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
 
 ## 模型
 
-- 内存中的单元状态具有最终决定权;每个写入原语都会通过临时文件写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。
-- 缺失文件会作为空单元打开,并在第一次写入时物化。外来或无法解析的文件以 `malformed-medium` 拒绝;已存版本与描述符不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。
+- `single` 布局:内存中的单元状态具有最终决定权;每个写入原语都会通过临时文件写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。
+- 缺失文件(`per-record` 时是缺失单元目录)会作为空单元打开,并在第一次写入时物化。`single` 布局下外来或无法解析的文件以 `malformed-medium` 拒绝;已存版本与描述符不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。`per-record` 布局的契约改为按记录:畸形或版本戳不符的文档读作"无此记录",单个坏文件或过期文件不会拖垮整个单元,版本升级按记录丢弃过期行而不是拒绝整个单元。
+- 空的 `per-record` 目录树会从旧 `<unit>.json` 整单元文件引导其已声明表的记录,并原样保留旧文件。已声明表中只要存在任意新布局文档路径,或已声明 global 对应的 `global.json` 存在,就会对整个单元禁用引导,即使该文档不可读或版本陈旧;缺失记录仍保持缺失,不从旧状态补入。
 - 跨调用的写入顺序属于调用方(领域层的写入链);每次调用都具备原子性,并在完成时已达到持久状态。
 
 ## 配置

+ 40 - 1
packages/storage/storage-json/src/format.ts

@@ -1,7 +1,11 @@
 /**
  * On-disk JSON unit format: the file is always the current net state, kept
  * human-readable (pretty-printed, stable key order from insertion) — that
- * legibility is this backend's reason to exist.
+ * legibility is this backend's reason to exist. `single`-layout units are
+ * one document with a unit header; `per-record`-layout units are a directory
+ * with one version-stamped document per record (`<table>/<key>.json`) plus a
+ * `global.json` for the global slot, so a write rewrites one record instead
+ * of the whole unit.
  * @module @deepseek-ai/dsh-storage-json/src/format
  */
 
@@ -82,3 +86,38 @@ export function parse(text: string, descriptor: KvUnitDescriptor): UnitState {
   }
   return state
 }
+
+/**
+ * Serialize one per-record document: the unit's version stamp plus the
+ * record value, pretty-printed like the whole-unit document.
+ * @param version - Unit format version, stamped into the header.
+ * @param value - The record value (or the global singleton value).
+ * @returns pretty-printed JSON document with a trailing newline.
+ */
+export function serializeRecord(version: number, value: unknown): string {
+  return `${JSON.stringify({ version, record: value }, null, 2)}\n`
+}
+
+/**
+ * Parse one per-record document, validating its version stamp. A document
+ * that is malformed or stamped with a different version is FOREIGN and reads
+ * as absent — the per-record contract: one bad or stale record file must not
+ * brick the whole unit, and a version bump discards stale records instead of
+ * migrating them (the whole-unit format rejects instead, because there is
+ * exactly one document).
+ * @param text - Raw per-record document content.
+ * @param version - Expected unit version; a mismatch discards the document.
+ * @returns the record value, or `undefined` for a foreign document.
+ */
+export function parseRecord(text: string, version: number): unknown {
+  let document: unknown
+  try {
+    document = JSON.parse(text)
+  } catch {
+    return undefined
+  }
+  if (typeof document !== 'object' || document === null) return undefined
+  const { version: stamped, record } = document as Record<string, unknown>
+  if (stamped !== version) return undefined
+  return record
+}

+ 13 - 8
packages/storage/storage-json/src/index.ts

@@ -1,17 +1,18 @@
 /**
- * JSON storage backend: one human-readable file per unit under a configured
- * root, published by atomic whole-file rewrite. Registers as backend `json`
- * on the storage hub.
+ * JSON storage backend: one human-readable document per unit under a
+ * configured root — a whole-unit file (`single` layout) or one document per
+ * record (`per-record` layout), published by atomic rewrite. Registers as
+ * backend `json` on the storage hub.
  * @module @deepseek-ai/dsh-storage-json
  */
 
 import { mkdir } from 'node:fs/promises'
-import { join } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
 import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
-import { openJsonUnit } from './unit.ts'
+import { openSingleUnit } from './single-unit.ts'
+import { openPerRecordUnit } from './per-record-unit.ts'
 
 /** Cordis plugin name. */
 export const name = 'storage-json'
@@ -25,7 +26,7 @@ export const inject = ['storage']
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 
@@ -62,8 +63,12 @@ export class JsonStorageBackend implements StorageBackend {
 
   private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
     await mkdir(this.root, { recursive: true, mode: 0o700 })
-    const path = join(this.root, `${descriptor.name}.json`)
-    const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
+    // The two layouts differ in medium shape only; each opener owns its own
+    // path convention under the shared root.
+    const onClose = () => this.open.delete(descriptor.name)
+    const unit = descriptor.layout === 'per-record'
+      ? await openPerRecordUnit(descriptor, this.root, onClose)
+      : await openSingleUnit(descriptor, this.root, onClose)
     if (this.closed) {
       // The backend closed while this open was in flight: do not hand out a
       // live unit past close().

+ 275 - 0
packages/storage/storage-json/src/per-record-unit.ts

@@ -0,0 +1,275 @@
+/**
+ * One opened JSON unit in `per-record` layout: the unit is a directory at
+ * `dir`, holding one document per record under `<dir>/<table>/<key>.json`
+ * plus `global.json` for the global slot. The directory is the state — this
+ * unit holds NO in-memory state of its own: `loadAll` re-reads the tree and
+ * every write is one durable file operation. The domain layer owns the live
+ * in-memory tables (seeded by the open-time `loadAll`) and serializes writes
+ * through its write chain, so this unit never mutates memory and needs no
+ * rollback — a failed write simply leaves both the file and the domain's
+ * memory unchanged.
+ *
+ * Per-record contract: a record document that is malformed or stamped with a
+ * different version reads as an absent record — one bad or stale file never
+ * bricks the whole unit, and a version bump discards stale records instead
+ * of migrating them. Record keys become path segments, so they must be
+ * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
+ *
+ * Legacy bootstrap: when the new tree has no document path, a legacy
+ * whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
+ * per-record documents. Any new document path, including one whose contents
+ * are unreadable or stale, suppresses the bootstrap for the whole unit. The
+ * legacy file is never changed or deleted.
+ * @module @deepseek-ai/dsh-storage-json/src/per-record-unit
+ */
+
+import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { Dirent } from 'node:fs'
+import { StorageError } from '@deepseek-ai/dsh-storage'
+import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
+import { writeAtomic } from './atomic.ts'
+import { parseRecord, serializeRecord } from './format.ts'
+import type { UnitState } from './format.ts'
+
+/** Keys become path segments in this layout; this set is path-safe on every OS. */
+const SAFE_KEY_RE = /^[a-zA-Z0-9_-]+$/
+
+/**
+ * Open one `per-record`-layout unit under `root`: the unit directory is
+ * `<root>/<name>/`. Loads lazily on the first `loadAll` — this unit holds no
+ * state, so opening touches nothing on the medium.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param root - Absolute backend root directory.
+ * @param onClose - Backend callback releasing the unit's open-slot.
+ * @returns the opened unit.
+ */
+// oxlint-disable-next-line typescript/require-await -- async keeps both openers' call sites uniform
+export async function openPerRecordUnit(
+  descriptor: KvUnitDescriptor,
+  root: string,
+  onClose: () => void,
+): Promise<KvUnit> {
+  return new PerRecordJsonUnit(descriptor, join(root, descriptor.name), onClose)
+}
+
+/**
+ * Read every record document under the unit directory: each declared table's
+ * `<key>.json` files plus `global.json`. A missing directory is the empty
+ * unit (materialization defers to the first write); a foreign document
+ * (missing, malformed, or stamped with another version) reads as an absent
+ * record, per the per-record contract.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param dir - Absolute unit directory path.
+ * @returns the authoritative state reconstructed from the tree.
+ */
+async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Promise<UnitState> {
+  const state: UnitState = {
+    version: descriptor.version,
+    global: null,
+    tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
+  }
+  let entries: Dirent[] | undefined
+  try {
+    entries = await readdir(dir, { withFileTypes: true })
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    // Missing directory = empty unit; the legacy bootstrap below still runs
+    // (the fresh-upgrade shape is exactly an absent new tree).
+  }
+  const hasNewDocuments = entries === undefined
+    ? false
+    : (await Promise.all(entries.map(async (entry) => {
+      if (entry.isDirectory()) {
+        const records = state.tables.get(entry.name)
+        if (records !== undefined) {
+          return loadTableRecords(records, descriptor.version, join(dir, entry.name))
+        }
+      }
+      if (entry.name === 'global.json' && descriptor.hasGlobal) {
+        const global = await readRecord(join(dir, entry.name), descriptor.version)
+        if (global !== undefined) state.global = global
+        return true
+      }
+      return false
+    }))).some(Boolean)
+  if (!hasNewDocuments) await bootstrapLegacyUnit(descriptor, dir, state)
+  return state
+}
+
+/**
+ * Bootstrap an empty per-record tree from a legacy whole-unit file
+ * (`<root>/<name>.json`, the pre-per-record layout). Every declared-table
+ * record is copied into a current-version document, while the legacy file is
+ * retained unchanged. A missing, foreign (another unit's name), malformed,
+ * or non-unit legacy file is left alone; other read failures propagate.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param dir - The per-record unit directory (`<root>/<name>`).
+ * @param state - The empty tree state; bootstrapped records are added.
+ */
+async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, state: UnitState): Promise<void> {
+  const legacyPath = join(dirname(dir), `${descriptor.name}.json`)
+  let text: string | undefined
+  try {
+    text = await readFile(legacyPath, 'utf8')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return
+  }
+  // The legacy document is runtime data: only `unit.name` and the tables map
+  // shape are checked here — the record values are migrated as-is and the
+  // domain layer's schemas judge them.
+  let document: { unit?: { name?: unknown }; tables?: unknown }
+  try {
+    document = JSON.parse(text) as { unit?: { name?: unknown }; tables?: unknown }
+  } catch {
+    return // Malformed legacy file: not ours to interpret or delete.
+  }
+  if (document.unit?.name !== descriptor.name) return
+  const tables = document.tables
+  if (typeof tables !== 'object' || tables === null) return
+  const recordsByTable = tables as Record<string, Record<string, unknown>>
+  for (const [table, records] of Object.entries(recordsByTable)) {
+    const target = state.tables.get(table)
+    if (target === undefined) continue
+    for (const [key, value] of Object.entries(records)) {
+      const path = join(dir, table, `${key}.json`)
+      await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+      await writeAtomic(path, serializeRecord(descriptor.version, value))
+      target.set(key, value)
+    }
+  }
+}
+
+/**
+ * Read one declared table's record documents into `records`.
+ * @returns whether the directory contains any `.json` document path,
+ * independently of key safety, readability, or stored version.
+ */
+async function loadTableRecords(records: Map<string, unknown>, version: number, dir: string): Promise<boolean> {
+  const files = await readdir(dir, { withFileTypes: true })
+  const hasDocuments = files.some(file => file.name.endsWith('.json'))
+  const loaded = await Promise.all(files.map(async (file) => {
+    if (!file.name.endsWith('.json')) return
+    const key = file.name.slice(0, -'.json'.length)
+    if (!SAFE_KEY_RE.test(key)) return
+    const record = await readRecord(join(dir, file.name), version)
+    if (record !== undefined) return [key, record] as const
+  }))
+  for (const record of loaded) {
+    if (record !== undefined) records.set(...record)
+  }
+  return hasDocuments
+}
+
+/** Read one record document; a foreign (unreadable or stale) one reads as absent. */
+async function readRecord(path: string, version: number): Promise<unknown> {
+  try {
+    return parseRecord(await readFile(path, 'utf8'), version)
+  } catch {
+    return undefined
+  }
+}
+
+/**
+ * One opened `per-record`-layout unit. Stateless by design: the directory is
+ * the medium, the domain layer owns the live memory, and each method here is
+ * a single durable file operation. Write ordering belongs to the caller (the
+ * domain layer's write chain), exactly like the `single`-layout unit.
+ */
+export class PerRecordJsonUnit implements KvUnit {
+  private closed = false
+  /** In-flight durable writes; close() drains them before releasing the unit. */
+  private readonly inFlight = new Set<Promise<void>>()
+
+  constructor(
+    private readonly descriptor: KvUnitDescriptor,
+    private readonly dir: string,
+    private readonly onClose: () => void,
+  ) {}
+
+  /** Re-read the tree: the directory is the authoritative state. */
+  async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
+    this.assertOpen()
+    const state = await loadPerRecordState(this.descriptor, this.dir)
+    const tables: Record<string, Record<string, unknown>> = {}
+    for (const [table, records] of state.tables) {
+      tables[table] = Object.fromEntries(records)
+    }
+    return { tables, global: state.global }
+  }
+
+  /** Durably replace one record: its own document, atomically. */
+  async putRecord(table: string, key: string, value: unknown): Promise<void> {
+    this.assertOpen()
+    assertSafeKey(this.descriptor.name, key)
+    await this.tracked(this.writeDocument(join(this.tableDir(table), `${key}.json`), value))
+  }
+
+  /** Durably delete one record. Idempotent: a missing key is a no-op. */
+  async deleteRecord(table: string, key: string): Promise<void> {
+    this.assertOpen()
+    assertSafeKey(this.descriptor.name, key)
+    await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true }))
+  }
+
+  /** Durably replace the global singleton. Only valid when declared. */
+  async setGlobal(value: unknown): Promise<void> {
+    this.assertOpen()
+    if (!this.descriptor.hasGlobal) {
+      throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
+    }
+    await this.tracked(this.writeDocument(join(this.dir, 'global.json'), value))
+  }
+
+  /* jscpd:ignore-start -- the two unit classes are standalone; the drain/guard lifecycle mirrors the shared KvUnit contract */
+  /** Drain in-flight writes and release the unit. Idempotent. */
+  async close(): Promise<void> {
+    if (this.closed) {
+      await Promise.allSettled(this.inFlight)
+      return
+    }
+    this.closed = true
+    await Promise.allSettled(this.inFlight)
+    this.onClose()
+  }
+
+  private assertOpen(): void {
+    if (this.closed) {
+      throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
+    }
+  }
+  /* jscpd:ignore-end */
+
+  /** Resolve a declared table's directory; an undeclared table is a caller bug and throws. */
+  private tableDir(table: string): string {
+    if (!this.descriptor.tables.includes(table)) {
+      throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`)
+    }
+    return join(this.dir, table)
+  }
+
+  /** Durably replace one document, creating its parent directory. */
+  private writeDocument(path: string, value: unknown): Promise<void> {
+    return (async () => {
+      await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+      await writeAtomic(path, serializeRecord(this.descriptor.version, value))
+    })()
+  }
+
+  /** Track one durable write so close() drains it. */
+  private tracked(write: Promise<void>): Promise<void> {
+    this.inFlight.add(write)
+    // Swallow only on the tracking branch: the caller still awaits `write`
+    // itself, so rejections stay observed exactly once.
+    write.catch(() => {}).finally(() => this.inFlight.delete(write))
+    return write
+  }
+}
+
+/** Reject a record key that would be unsafe as a path segment. */
+function assertSafeKey(unit: string, key: string): void {
+  if (!SAFE_KEY_RE.test(key)) {
+    throw new Error(`unit '${unit}': per-record key '${key}' is not path-safe (must match ${SAFE_KEY_RE})`)
+  }
+}

+ 16 - 9
packages/storage/storage-json/src/unit.ts → packages/storage/storage-json/src/single-unit.ts

@@ -1,13 +1,16 @@
 /**
- * One opened JSON unit. The in-memory state is authoritative; every write
+ * One opened JSON unit in `single` layout: the whole unit is one document at
+ * `<root>/<name>.json`. The in-memory state is authoritative; every write
  * primitive mutates it and republishes the whole file atomically. Writes are
  * NOT queued here — per the backend contract, write ordering belongs to the
  * caller (the domain layer's write chain); this unit only guarantees that
- * each single call publishes a complete, durable file.
- * @module @deepseek-ai/dsh-storage-json/src/unit
+ * each single call publishes a complete, durable file. The `per-record`
+ * layout is a separate unit class in `per-record-unit.ts`.
+ * @module @deepseek-ai/dsh-storage-json/src/single-unit
  */
 
 import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
 import { StorageError } from '@deepseek-ai/dsh-storage'
 import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
 import { writeAtomic } from './atomic.ts'
@@ -15,17 +18,19 @@ import { parse, serialize } from './format.ts'
 import type { UnitState } from './format.ts'
 
 /**
- * Open (load or lazily create) one unit backed by `path`.
+ * Open (load or lazily create) one `single`-layout unit under `root`: the
+ * unit file is `<root>/<name>.json`.
  * @param descriptor - Static identity and shape of the unit.
- * @param path - Absolute unit file path under the backend root.
+ * @param root - Absolute backend root directory.
  * @param onClose - Backend callback releasing the unit's open-slot.
  * @returns the opened unit.
  */
-export async function openJsonUnit(
+export async function openSingleUnit(
   descriptor: KvUnitDescriptor,
-  path: string,
+  root: string,
   onClose: () => void,
 ): Promise<KvUnit> {
+  const path = join(root, `${descriptor.name}.json`)
   let text: string | undefined
   try {
     text = await readFile(path, 'utf8')
@@ -41,10 +46,10 @@ export async function openJsonUnit(
         tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
       }
       : parse(text, descriptor)
-  return new JsonKvUnit(descriptor, path, state, onClose)
+  return new SingleJsonUnit(descriptor, path, state, onClose)
 }
 
-class JsonKvUnit implements KvUnit {
+class SingleJsonUnit implements KvUnit {
   private closed = false
   /** In-flight publishes; close() drains them before releasing the unit. */
   private readonly inFlight = new Set<Promise<void>>()
@@ -106,6 +111,7 @@ class JsonKvUnit implements KvUnit {
     })
   }
 
+  /* jscpd:ignore-start -- the two unit classes are standalone; the drain/guard lifecycle mirrors the shared KvUnit contract */
   async close(): Promise<void> {
     if (this.closed) {
       await Promise.allSettled(this.inFlight)
@@ -121,6 +127,7 @@ class JsonKvUnit implements KvUnit {
       throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
     }
   }
+  /* jscpd:ignore-end */
 
   private records(table: string): Map<string, unknown> {
     const records = this.state.tables.get(table)

+ 194 - 1
packages/storage/storage-json/tests/json-backend.spec.ts

@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
@@ -226,3 +226,196 @@ describe('json backend specifics', () => {
     await closing
   })
 })
+
+describe('per-record layout', () => {
+  const descriptor = { name: 'recs', version: 2, layout: 'per-record' as const, tables: ['t'], hasGlobal: true }
+  const recordPath = (root: string, key: string): string => join(root, 'recs', 't', `${key}.json`)
+
+  it('stores one version-stamped document per record and defers materialization', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    // Missing directory = empty unit; nothing materialized on the medium yet.
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await unit.putRecord('t', 'k1', { v: 1 })
+    await unit.putRecord('t', 'k2', { v: 2 })
+    await unit.setGlobal('G')
+    expect(await readFile(recordPath(root, 'k1'), 'utf8'))
+      .toBe(`${JSON.stringify({ version: 2, record: { v: 1 } }, null, 2)}\n`)
+    expect((await readdir(join(root, 'recs', 't'))).sort()).toEqual(['k1.json', 'k2.json'])
+    expect(JSON.parse(await readFile(join(root, 'recs', 'global.json'), 'utf8')))
+      .toEqual({ version: 2, record: 'G' })
+    expect(await unit.loadAll()).toEqual({ tables: { t: { k1: { v: 1 }, k2: { v: 2 } } }, global: 'G' })
+    await backend.close()
+  })
+
+  it('overwrites and deletes one document at a time and persists across reopen', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await unit.putRecord('t', 'k', { v: 1 })
+    await unit.putRecord('t', 'k', { v: 2 }) // overwrite the same document
+    await unit.deleteRecord('t', 'missing') // idempotent no-op
+    await unit.close()
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null })
+    await unit2.deleteRecord('t', 'k')
+    expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await backend.close()
+  })
+
+  it('rejects unsafe keys and undeclared tables, and enforces the closed guard', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await expect(unit.putRecord('t', 'a/b', {})).rejects.toThrow(/not path-safe/)
+    await expect(unit.deleteRecord('t', '..')).rejects.toThrow(/not path-safe/)
+    await expect(unit.putRecord('bogus', 'k', {})).rejects.toThrow(/does not declare table/)
+    await unit.close()
+    await expect(unit.putRecord('t', 'k', {})).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.deleteRecord('t', 'k')).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.setGlobal('x')).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
+    await backend.close()
+  })
+
+  it('discards foreign documents (stale version, malformed, non-object, unsafe key) on open', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await unit.putRecord('t', 'good', { v: 1 })
+    await unit.close()
+    await writeFile(recordPath(root, 'stale'), JSON.stringify({ version: 1, record: { v: 0 } }), 'utf8')
+    await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
+    await writeFile(recordPath(root, 'scalar'), JSON.stringify(5), 'utf8')
+    await writeFile(recordPath(root, 'unsafe%2Fkey'), JSON.stringify({ version: 2, record: { v: 0 } }), 'utf8')
+    await writeFile(join(root, 'recs', 't', 'not-json.txt'), 'ignored', 'utf8')
+    await writeFile(join(root, 'recs', 'global.json'), JSON.stringify({ version: 1, record: 'old' }), 'utf8')
+    // Stray unit-root entries: an undeclared directory and a non-document file.
+    await mkdir(join(root, 'recs', 'stray-dir'), { recursive: true })
+    await writeFile(join(root, 'recs', 'stray.txt'), 'ignored', 'utf8')
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { good: { v: 1 } } }, global: null })
+    await backend.close()
+  })
+
+  it('propagates non-ENOENT read failures and refuses a global slot that is not declared', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    // A file where the unit directory should be: the lazy loadAll readdir
+    // fails with ENOTDIR (opening itself touches nothing on the medium).
+    await writeFile(join(root, 'recs'), 'not a directory', 'utf8')
+    const unit = await backend.kv.open(descriptor)
+    await expect(unit.loadAll()).rejects.toMatchObject({ code: 'ENOTDIR' })
+    await unit.close()
+    const noGlobal = { name: 'plain', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false }
+    const unit2 = await backend.kv.open(noGlobal)
+    await expect(unit2.setGlobal('x')).rejects.toThrow(/does not declare a global slot/)
+    await backend.close()
+  })
+
+  it('close drains in-flight writes and an unreadable record document reads as absent', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    const big = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
+    await unit.close()
+    await unit.close() // idempotent
+    await expect(big).resolves.toBeUndefined()
+    const onDisk = JSON.parse(await readFile(recordPath(root, 'big'), 'utf8')) as { record: { blob: string } }
+    expect(onDisk.record).toEqual({ blob: 'x'.repeat(4 * 1024 * 1024) })
+    await backend.close()
+  })
+
+  it('reads an unreadable record document as absent (per-record contract)', async () => {
+    const root = await freshRoot()
+    // A directory where the record document should be: readFile fails with
+    // EISDIR on every platform (permission bits are unenforceable on win32).
+    await mkdir(join(root, 'recs', 't', 'locked.json'), { recursive: true })
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await backend.close()
+  })
+
+  it('bootstraps an empty per-record tree from a legacy whole-unit file and preserves it', async () => {
+    const root = await freshRoot()
+    // A legacy single-layout file for the same unit (any older version);
+    // the extra table is not declared and must be skipped.
+    const legacy = JSON.stringify({
+      unit: { name: 'recs', version: 3 },
+      global: null,
+      tables: { t: { old1: { v: 1 }, old2: { v: 2 } }, undeclared: { k: { v: 0 } } },
+    })
+    await writeFile(join(root, 'recs.json'), legacy, 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
+    await unit.close()
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
+    await backend.close()
+  })
+
+  it('ignores the legacy whole-unit file when any new document path exists', async () => {
+    const root = await freshRoot()
+    const legacy = JSON.stringify({
+      unit: { name: 'recs', version: 1 },
+      global: null,
+      tables: { t: { old: { v: 1 } } },
+    })
+    await writeFile(join(root, 'recs.json'), legacy, 'utf8')
+    await mkdir(join(root, 'recs', 't'), { recursive: true })
+    await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(recordPath(root, 'old'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
+    await backend.close()
+  })
+
+  it('leaves a foreign, shapeless, or malformed legacy file alone', async () => {
+    const root = await freshRoot()
+    await writeFile(join(root, 'recs.json'), JSON.stringify({ unit: { name: 'other', version: 3 }, tables: {} }), 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toContain('other')
+    await unit.close()
+    await backend.close()
+
+    const root2 = await freshRoot()
+    await writeFile(join(root2, 'recs.json'), JSON.stringify({ tables: { t: { k: { v: 1 } } } }), 'utf8')
+    const backend2 = new JsonStorageBackend(root2)
+    const unit2 = await backend2.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root2, 'recs.json'), 'utf8')).resolves.toContain('tables')
+    await backend2.close()
+
+    const root3 = await freshRoot()
+    // A directory where the legacy file should be: the migration read fails loudly.
+    await mkdir(join(root3, 'recs.json'))
+    const backend3 = new JsonStorageBackend(root3)
+    const unit3 = await backend3.kv.open(descriptor)
+    await expect(unit3.loadAll()).rejects.toMatchObject({ code: 'EISDIR' })
+    await backend3.close()
+
+    const root4 = await freshRoot()
+    await writeFile(join(root4, 'recs.json'), 'not json at all', 'utf8')
+    const backend4 = new JsonStorageBackend(root4)
+    const unit4 = await backend4.kv.open(descriptor)
+    expect(await unit4.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root4, 'recs.json'), 'utf8')).resolves.toBe('not json at all')
+    await backend4.close()
+
+    const root5 = await freshRoot()
+    await writeFile(join(root5, 'recs.json'), JSON.stringify({ unit: { name: 'recs' }, tables: 'not an object' }), 'utf8')
+    const backend5 = new JsonStorageBackend(root5)
+    const unit5 = await backend5.kv.open(descriptor)
+    expect(await unit5.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root5, 'recs.json'), 'utf8')).resolves.toContain('not an object')
+    await backend5.close()
+  })
+})

+ 12 - 1
packages/storage/storage/src/backend.ts

@@ -52,6 +52,15 @@ export interface KvUnitDescriptor {
   readonly tables: readonly string[]
   /** Whether this unit carries the global singleton slot. */
   readonly hasGlobal: boolean
+  /**
+   * Medium layout. `single` (the default) keeps the whole unit in one
+   * document; `per-record` keeps each record in its own document, so a unit
+   * whose records are large or sparse never rewrites the rest on one write,
+   * and a version bump discards stale records instead of rejecting the whole
+   * unit. Backends that only serve one layout accept the other's units as
+   * foreign documents.
+   */
+  readonly layout?: 'single' | 'per-record'
 }
 
 /**
@@ -74,7 +83,9 @@ export interface KvUnit {
   /**
    * Upsert one record durably. Overwrite semantics: an existing key is replaced.
    * @param table - Declared table name.
-   * @param key - Record key; any string is safe (keys never reach file paths).
+   * @param key - Record key. In the `per-record` layout a key becomes a path
+   * segment and must match `[a-zA-Z0-9_-]+` (an unsafe key rejects); in the
+   * `single` layout keys stay opaque.
    * @param value - Opaque JSON-serializable record.
    * @returns resolution after durability.
    */

+ 2 - 2
packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md
-README.md: d9b29b594c13cd98cf4eaf3b4c8f93caf935a82c
-README.zh.md: 9e1167f4d76cd93860c73e239f689512df38cb2b
+README.md: e861a14b1de2d8c9760a883a01773667ee5a222b
+README.zh.md: c1d46ba486a642f15382c13b175203f4f9fe03cd

+ 27 - 4
packages/subagent/subagent-dsh-sdk/README.md

@@ -6,17 +6,40 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
 
 ## Start and ownership
 
-`start(request)` resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains omitted unless the request supplies it. The provider then spawns through `DeepSeekHarness` and completes the child runtime's `initialize` handshake, including exact-model and effort validation, before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A route, spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
+`start(request)` rejects an already-aborted request, then resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains omitted unless the request supplies it. The provider then spawns through `DeepSeekHarness` and completes the child runtime's `initialize` handshake, including exact-model and effort validation, before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A route, spawn, handshake, or pre-publication cancellation failure ordinarily rejects after the subprocess is reaped; when cleanup itself rejects, ordered safe facts preserve initialize plus shutdown for an ordinary failure, or shutdown alone after cancellation, without claiming complete process quiescence. A working-directory resolution failure rejects before anything is spawned. Non-cancellation rejections expose only fixed provider, stage, and category facts in their Error message; the original SDK failure remains on the internal cause chain and in Host diagnostics.
 
 The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. `dshHome` is separately required as an absolute path so a nested runtime cannot accidentally share its parent's profiles, plugin installation, or session storage.
 
-The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error.
+The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error, separate from any `SubagentResult.diagnostic`.
 
 `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
 
 ## Stop-reason mapping
 
-The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and maps it into the seam vocabulary: `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or an activity with no turn — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting.
+The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and preserves the existing seam stop reason while adding detail only where it changes the next action.
+
+| Child turn reason | Harness | Additional diagnostic |
+|---|---|---|
+| `completed` | `completed` | None. |
+| `max-tokens` | `max-tokens` | None; the stop reason is already actionable. |
+| `aborted` | `aborted` | `child-disposed` only for the closed `disposed` cause; local parent cancellation never adds one. |
+| `blocked` | `refusal` | None; the shared stop reason already identifies a declined task. |
+| `error` | `error` | `child-error`; the child failure message/code is excluded. |
+| `interrupted` | `error` | None; only persistence repair produces it, and this provider creates fresh sessions. |
+| no `turn/end` | `error` | `missing-terminal`. |
+| unknown variant | `error` | Fixed `child-unknown`; the value is not copied. |
+
+## Failure diagnostics
+
+The first line follows the shared fixed form:
+
+```text
+Subagent failure (provider: DSH SDK; stage: <stage>; category: <category>)
+```
+
+The shared result boundary limits the complete text to 4096 UTF-8 bytes. The provider derives `initialize`, `session-run`, or `shutdown` at the operation that owns the failure. During initialize or session run, `SdkProtocolError` and JSON-RPC error responses map to `protocol`, `TransportClosedError` maps to `transport`, and other exceptions use `unknown`. A shutdown rejection uses `unknown`: the SDK client keeps protocol-shutdown failures in Host diagnostics, so only runtime-process disposal can reject `close()`. Classification never reads an error message, so the stderr tail carried by `TransportClosedError`, paths, task content, environment values, credentials, and protocol payloads remain Host-only. Request timeout classification is deferred until this provider configures or propagates a request timeout; the current SDK launch waits indefinitely for ordinary requests.
+
+Successful results and local cancellation omit diagnostics. Startup and shutdown rejections use the same safe line in their Error message while retaining the original cause internally. A diagnostic-bearing child `aborted` result remains `aborted`; the one-shot Job adapter classifies it as failed, while diagnostic-free local cancellation remains killed.
 
 ## Capabilities and context
 
@@ -84,7 +107,7 @@ Independent of the parent request cache. Each SDK child can reuse only prefixes
 
 #### What the model sees
 
-Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic.
+Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A diagnostic-bearing non-completed result presents the safe diagnostic before separately preserved partial assistant output; startup and shutdown errors expose the same fixed facts without raw SDK text.
 
 #### Token effect
 

+ 27 - 4
packages/subagent/subagent-dsh-sdk/README.zh.md

@@ -6,17 +6,40 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
 
 ## 启动与所有权
 
-`start(request)` 会在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider`、`model`、`reasoningEffort` 或 `maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 `DeepSeekHarness` spawn 运行时,并在履行前完成子运行时的 `initialize` 握手,其中包括确切模型与推理强度校验。因此,履行意味着子运行时已就绪、所有权已移交给调用方。路由、spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
+`start(request)` 会先拒绝已经取消的请求,再在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider`、`model`、`reasoningEffort` 或 `maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 `DeepSeekHarness` spawn 运行时,并在履行前完成子运行时的 `initialize` 握手,其中包括确切模型与推理强度校验。因此,履行意味着子运行时已就绪、所有权已移交给调用方。路由、spawn、握手或发布前取消失败通常会在子进程被回收后拒绝;若清理自身也拒绝,有序的安全事实会在普通失败时保留 initialize 与 shutdown,在取消后只保留 shutdown,且不会宣称进程已经完全停稳。工作目录解析失败则会在尚未 spawn 任何内容时拒绝。非取消拒绝的 Error 消息只公开固定的 provider、stage 与 category 事实;原始 SDK 失败仍保留在内部 cause 链和 Host 诊断中。
 
 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。`dshHome` 必须另外指定为绝对路径,使嵌套运行时不会意外共享父运行时的 profile、插件安装或会话存储。
 
-返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。
+返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用,并与 `SubagentResult.diagnostic` 分开。
 
 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
 
 ## 停止原因映射
 
-SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个已持久化的 `turn/end`,并将其映射为 seam 词汇:`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或不含轮次的活动,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 约定禁止 `result` 被拒绝。
+SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个已持久化的 `turn/end`,保留既有 seam 结束原因,并只在会改变下一步动作时附加细节。
+
+| 子轮次原因 | Harness | 附加诊断 |
+|---|---|---|
+| `completed` | `completed` | 无。 |
+| `max-tokens` | `max-tokens` | 无;结束原因本身已经可行动。 |
+| `aborted` | `aborted` | 只有闭集 `disposed` 原因会附加 `child-disposed`;父级本地取消绝不附加。 |
+| `blocked` | `refusal` | 无;共享结束原因已经表示任务被拒绝。 |
+| `error` | `error` | `child-error`;不包含子失败消息或 code。 |
+| `interrupted` | `error` | 无;只有持久化修复会产生该原因,而本提供方创建全新会话。 |
+| 缺少 `turn/end` | `error` | `missing-terminal`。 |
+| 未知 variant | `error` | 固定 `child-unknown`,不复制原值。 |
+
+## 失败诊断
+
+首行遵循共享固定格式:
+
+```text
+Subagent failure (provider: DSH SDK; stage: <stage>; category: <category>)
+```
+
+共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。提供方从实际拥有失败的操作派生 `initialize`、`session-run` 或 `shutdown`。在 initialize 或 session run 期间,`SdkProtocolError` 与 JSON-RPC 错误响应映射为 `protocol`,`TransportClosedError` 映射为 `transport`,其他异常使用 `unknown`。shutdown 拒绝使用 `unknown`:SDK 客户端会把协议 shutdown 失败留在 Host 诊断中,因此只有运行时进程释放能让 `close()` 拒绝。分类绝不读取错误消息,因此 `TransportClosedError` 携带的 stderr tail、路径、任务内容、环境值、凭证与协议 payload 都只留在 Host。请求超时分类会推迟到本提供方实际配置或传播 request timeout 时;当前 SDK launch 会无限等待普通请求。
+
+成功结果与本地取消会省略诊断。启动和 shutdown 拒绝会在 Error 消息中使用同一安全行,同时把原始 cause 留在内部。带诊断的子 `aborted` 结果仍保持 `aborted`;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
 
 ## 能力与上下文
 
@@ -84,7 +107,7 @@ SDK 客户端返回自有子活动,而不是提示词结果。提供方读取
 
 #### 模型看到的内容
 
-经由 `dsh-tool-subagent`,父级只会收到子运行时最终的 assistant 文本(或累积的部分文本),或该消费方给出的精确停止原因错误;不会收到中间消息或工具流量。
+经由 `dsh-tool-subagent`,父级只会收到子运行时最终的 assistant 文本(或累积的部分文本),或该消费方给出的精确停止原因错误;不会收到中间消息或工具流量。带诊断的非完成结果会先呈现安全诊断,再单独呈现保留的部分 assistant 输出;启动与 shutdown 错误使用同一固定事实,不公开原始 SDK 文本。
 
 #### Token 影响
 

+ 13 - 1
packages/subagent/subagent-dsh-sdk/src/index.ts

@@ -22,6 +22,7 @@ import {
   DEFAULT_DISPOSE_EOF_GRACE_MS,
   DEFAULT_DISPOSE_GRACE_MS,
   DEFAULT_SHUTDOWN_TIMEOUT_MS,
+  sdkConfigurationFailure,
   startSdkRun,
   type SdkRunSpec,
 } from './run.ts'
@@ -141,13 +142,24 @@ class SdkSubagentProvider implements SubagentProvider {
   }
 
   start(request: SubagentStartRequest) {
+    if (request.signal.aborted) {
+      throw new Error('subagent request was aborted before the SDK child started')
+    }
+    let cwd: string
+    try {
+      cwd = resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd)
+    } catch (error: unknown) {
+      const failure = sdkConfigurationFailure(error)
+      this.ctx.logger.warn(`subagent-dsh-sdk "${this.name}": child start failed: %o`, error)
+      throw failure
+    }
     const route = resolveSdkRoute(this.config, request.agentOptions)
     const spec: SdkRunSpec = {
       ...this.config.dshBin === undefined ? {} : { dshBin: this.config.dshBin },
       profile: this.config.profile,
       patches: this.config.patches,
       dshHome: this.config.dshHome,
-      cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
+      cwd,
       ...route,
       env: this.config.env,
       shutdownTimeoutMs: this.config.shutdownTimeoutMs,

+ 173 - 33
packages/subagent/subagent-dsh-sdk/src/run.ts

@@ -11,7 +11,14 @@
  */
 
 import { randomUUID } from 'node:crypto'
-import { DeepSeekHarness, type DeepSeekHarnessOptions, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client'
+import {
+  DeepSeekHarness,
+  type DeepSeekHarnessOptions,
+  type HarnessNotification,
+  JsonRpcResponseError,
+  SdkProtocolError,
+  TransportClosedError,
+} from '@deepseek-ai/dsh-sdk-client'
 import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
@@ -56,10 +63,9 @@ export interface SdkRunSpec {
   /** Termination confirmation window (ms), including forced exit on every platform. */
   disposeGraceMs: number
   /**
-   * Sink for a child-level failure that the run flattened into a stop reason
-   * (the seam contract forbids `result` rejecting). A throw from the sink
-   * itself is contained. Optional — omitted in unit tests that assert the
-   * stop reason directly.
+   * Host sink for startup, published-run, or shutdown failures. Model-visible
+   * text uses fixed safe facts, while this callback retains the original Error.
+   * A throw from the sink itself is contained.
    */
   onError?: (error: Error, stopReason: SubagentStopReason) => void
 }
@@ -73,31 +79,104 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000
 /** Default bound on the protocol `shutdown` exchange during dispose. */
 export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000
 
+type SdkFailureStage = 'initialize' | 'session-run' | 'shutdown'
+
+type SdkFailureCategory =
+  | 'configuration'
+  | 'protocol'
+  | 'transport'
+  | 'child-error'
+  | 'child-disposed'
+  | 'child-unknown'
+  | 'missing-terminal'
+  | 'unknown'
+
+interface SdkFailureFacts {
+  readonly stage: SdkFailureStage
+  readonly category: SdkFailureCategory
+}
+
+/** Fixed safe failure text derived only from provider-owned structured facts. */
+function failureDiagnostic(facts: SdkFailureFacts): string {
+  const fields = [
+    'provider: DSH SDK',
+    `stage: ${facts.stage}`,
+    `category: ${facts.category}`,
+  ]
+  return `Subagent failure (${fields.join('; ')})`
+}
+
+class SdkRunFailure extends Error {
+  constructor(readonly facts: SdkFailureFacts, cause: unknown) {
+    super(`subagent-dsh-sdk: ${failureDiagnostic(facts)}`, { cause })
+    this.name = 'SdkRunFailure'
+  }
+}
+
 /** Runtime constructor seam replaced only by package-local fake-runtime tests. */
 export const internals: { createHarness(options: DeepSeekHarnessOptions): DeepSeekHarness } = {
   createHarness: options => new DeepSeekHarness(options),
 }
 
 /**
- * Map a child turn-end reason to a harness {@link SubagentStopReason}.
+ * Hide a pre-spawn workspace/configuration failure behind fixed safe facts.
+ * @param cause - original Host failure retained on the Error cause chain.
+ * @returns an Error whose message contains only the fixed DSH SDK failure line.
+ */
+export function sdkConfigurationFailure(cause: unknown): Error {
+  return new SdkRunFailure({ stage: 'initialize', category: 'configuration' }, cause)
+}
+
+/** Classify one SDK rejection without reading its message or stderr tail. */
+function sdkFailure(error: unknown, stage: SdkFailureStage): SdkRunFailure {
+  const facts: SdkFailureFacts = error instanceof TransportClosedError
+    ? { stage, category: 'transport' }
+    : error instanceof SdkProtocolError || error instanceof JsonRpcResponseError
+      ? { stage, category: 'protocol' }
+      : { stage, category: 'unknown' }
+  return new SdkRunFailure(facts, error)
+}
+
+/**
+ * Map one child terminal reason to its complete shared result outcome.
  * @param reason - the owned child run's final durable turn reason, or
  * `undefined` when it settled without running a turn.
- * @returns the harness equivalent; an absent or unknown reason maps to
- * `error`, so an unclean stop is never reported as `completed`.
+ * @returns the shared stop reason and any additional safe diagnostic.
  */
-export function sdkStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
+export function sdkChildOutcome(
+  reason: TurnEndReason | undefined,
+): Pick<SubagentResult, 'stopReason' | 'diagnostic'> {
   switch (reason?.kind) {
     case 'completed':
-      return 'completed'
+      return { stopReason: 'completed' }
     case 'max-tokens':
-      return 'max-tokens'
+      return { stopReason: 'max-tokens' }
     case 'aborted':
-      return 'aborted'
-    // error / interrupted / disposed / a future merged variant /
-    // no turn at all: the task did NOT finish cleanly — surface a generic
-    // failure so the consumer maps it to an isError result.
+      return reason.reason.kind === 'disposed'
+        ? {
+          stopReason: 'aborted',
+          diagnostic: failureDiagnostic({ stage: 'session-run', category: 'child-disposed' }),
+        }
+        : { stopReason: 'aborted' }
+    case 'blocked':
+      return { stopReason: 'refusal' }
+    case 'error':
+      return {
+        stopReason: 'error',
+        diagnostic: failureDiagnostic({ stage: 'session-run', category: 'child-error' }),
+      }
+    case 'interrupted':
+      return { stopReason: 'error' }
+    case undefined:
+      return {
+        stopReason: 'error',
+        diagnostic: failureDiagnostic({ stage: 'session-run', category: 'missing-terminal' }),
+      }
     default:
-      return 'error'
+      return {
+        stopReason: 'error',
+        diagnostic: failureDiagnostic({ stage: 'session-run', category: 'child-unknown' }),
+      }
   }
 }
 
@@ -110,10 +189,40 @@ function toError(value: unknown): Error {
   return value instanceof Error ? value : new Error(String(value))
 }
 
+/** Report an original Host failure without letting the observation sink replace it. */
+function reportFailure(spec: SdkRunSpec, error: unknown): void {
+  try {
+    spec.onError?.(toError(error), 'error')
+  } catch {
+    // Host diagnostic logging cannot replace the child failure.
+  }
+}
+
+/** Map an SDK-owned failed-start aggregate into safe initialize/shutdown lines. */
+function sdkStartupFailure(spec: SdkRunSpec, error: unknown): Error {
+  if (!(error instanceof AggregateError) || error.errors.length < 2) {
+    reportFailure(spec, error)
+    return sdkFailure(error, 'initialize')
+  }
+  const initializeError: unknown = error.errors[0]
+  const cleanupError: unknown = error.errors[1]
+  reportFailure(spec, initializeError)
+  reportFailure(spec, cleanupError)
+  const initializeFailure = sdkFailure(initializeError, 'initialize')
+  const cleanupFailure = new SdkRunFailure({ stage: 'shutdown', category: 'unknown' }, cleanupError)
+  return new AggregateError(
+    [initializeFailure, cleanupFailure],
+    `${initializeFailure.message}; ${cleanupFailure.message}`,
+  )
+}
+
 /**
  * Start and publish one SDK runtime child after its `initialize` handshake.
- * Child failures resolve through the run result; startup failures reject
- * after process reap. Disposal shuts the runtime down and reaps it.
+ * Child failures resolve through the run result. Startup rejects with fixed
+ * safe facts after SDK-owned cleanup; successful cleanup proves process reap.
+ * Cleanup failure preserves initialize plus shutdown for an ordinary failure,
+ * or shutdown alone after cancellation, without claiming quiescence. Disposal
+ * shuts the runtime down and reaps it.
  * @param request - the start request; its signal is the cancellation channel.
  * @param spec - the resolved spawn spec: profile/patches/home/cwd, the child's
  * provider/model/reasoning route, output cap, env, timeouts, and the optional
@@ -154,24 +263,35 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
   }
   const onAbort = (): void => { requestCancel() }
   request.signal.addEventListener('abort', onAbort, { once: true })
+  const cancelledStartup = new Error('subagent cancelled before the SDK child initialized')
 
   // Establish the child handshake before publishing a handle. Any failure
   // owns the still-private process and reaps it before rejecting.
   try {
     await Promise.race([
       harness.start(),
-      cancelSettled.then((): never => { throw new Error('subagent cancelled before the SDK child initialized') }),
+      cancelSettled.then((): never => { throw cancelledStartup }),
     ])
     // Defensive: an abort() is a macrotask and no user callback runs inside
     // the microtask drain between handshake fulfillment and this continuation,
     // so current callback ordering cannot schedule the recheck; it guards future reentrancy.
     /* v8 ignore next */
-    if (flags.cancelled) throw new Error('subagent cancelled before the SDK child initialized')
+    if (flags.cancelled) throw cancelledStartup
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
-    await harness.close()
-    if (flags.cancelled) throw new Error('subagent request was aborted before the SDK child started')
-    throw toError(error)
+    if (error !== cancelledStartup) {
+      throw sdkStartupFailure(spec, error)
+    }
+    try {
+      await harness.close()
+    } catch (cleanupError: unknown) {
+      reportFailure(spec, cleanupError)
+      const cleanupFailure = new SdkRunFailure({ stage: 'shutdown', category: 'unknown' }, cleanupError)
+      // Preserve failed cleanup as a failed Job; settleStart treats only an
+      // aborted non-AggregateError rejection as a cleanly killed startup.
+      throw new AggregateError([cleanupFailure], cleanupFailure.message)
+    }
+    throw new Error('subagent request was aborted before the SDK child started')
   }
 
   const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
@@ -183,22 +303,42 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
     fold.push(notification.params.event as SessionEvent)
   }
   const collectOutput = (): ContentBlock[] => fold.collect() ?? []
+  const teardown = async (): Promise<void> => {
+    try {
+      await harness.close()
+    } catch (error: unknown) {
+      reportFailure(spec, error)
+      throw new SdkRunFailure({ stage: 'shutdown', category: 'unknown' }, error)
+    }
+  }
 
   // Race the child turn against local cancellation; the shared settlement
   // flattens failures under the seam's never-reject contract.
+  let diagnostic: string | undefined
   const result: Promise<SubagentResult> = settleRunResult({
     attempt: async () => {
-      const turn = await Promise.race([
-        harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
-        cancelSettled.then(() => 'cancelled' as const),
-      ])
-      if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
-      const lastEnd = turn.events.findLast(
-        (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
-      )
-      return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) }
+      try {
+        const turn = await Promise.race([
+          harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
+          cancelSettled.then(() => 'cancelled' as const),
+        ])
+        if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
+        const lastEnd = turn.events.findLast(
+          (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
+        )
+        const outcome = sdkChildOutcome(lastEnd?.data.reason)
+        diagnostic = outcome.diagnostic
+        return {
+          output: collectOutput(),
+          ...outcome,
+        }
+      } catch (error: unknown) {
+        diagnostic = failureDiagnostic(sdkFailure(error, 'session-run').facts)
+        throw error
+      }
     },
     collectOutput,
+    collectDiagnostic: () => diagnostic,
     cancelled: () => flags.cancelled,
     onError: spec.onError,
     signal: request.signal,
@@ -213,6 +353,6 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
     signal: request.signal,
     onAbort,
     requestCancel,
-    teardown: () => harness.close(),
+    teardown,
   })
 }

+ 30 - 9
packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/child-mock-llm.ts

@@ -1,11 +1,13 @@
 import type { Context } from '@deepseek-ai/cordis'
+import { existsSync, writeFileSync } from 'node:fs'
+import { setTimeout } from 'node:timers/promises'
 import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 
 /**
- * Scripted model for the CHILD runtime: rejects any route drift, then reports
- * its effective route and process cwd so the driving evidence observes both
- * SDK initialization inputs and the inherited workspace.
+ * Scripted model for the CHILD runtime: validates either the routed success
+ * case or the diagnostic fixture's fixed route. Failure mode streams partial
+ * text before a fixed provider error so the parent can assert safe diagnostics.
  */
 class RouteEchoAdapter extends LlmAdapter {
   override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
@@ -20,10 +22,15 @@ class RouteEchoAdapter extends LlmAdapter {
   }
 
   async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
-    if (options.provider !== 'mock'
-      || options.model !== 'mock-routed'
-      || options.reasoningEffort !== 'max'
-      || options.maxTokens !== 777) {
+    const failure = process.env.DSH_TEST_CHILD_FAILURE === '1'
+    const dynamicRoute = options.provider === 'mock'
+      && options.model === 'mock-routed'
+      && options.reasoningEffort === 'max'
+      && options.maxTokens === 777
+    const diagnosticRoute = failure
+      && options.provider === 'mock'
+      && options.model === 'mock-echo'
+    if (!dynamicRoute && !diagnosticRoute) {
       throw new Error(`unexpected child route: ${JSON.stringify({
         provider: options.provider,
         model: options.model,
@@ -31,12 +38,26 @@ class RouteEchoAdapter extends LlmAdapter {
         maxTokens: options.maxTokens,
       })}`)
     }
-    const reply = `child route: mock/mock-routed/max/777; cwd: ${process.cwd()}`
+    const ready = process.env.FAKE_INIT_READY
+    const release = process.env.FAKE_INIT_GO
+    if (ready !== undefined) writeFileSync(ready, 'ready\n')
+    if (release !== undefined) {
+      const deadline = Date.now() + 30_000
+      while (!existsSync(release)) {
+        if (Date.now() > deadline) throw new Error(`child mock timed out waiting for ${release}`)
+        await setTimeout(10)
+      }
+    }
+    const reply = failure
+      ? 'partial child loader answer'
+      : `child route: mock/mock-routed/max/777; cwd: ${process.cwd()}`
     yield { type: 'block-start', index: 0, blockType: 'text' }
     yield { type: 'text-delta', index: 0, text: reply }
     yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
     yield { type: 'usage', usage: { inputTokens: 3, outputTokens: 5 } }
-    yield { type: 'finish', reason: { kind: 'stop' } }
+    yield failure
+      ? { type: 'finish', reason: { kind: 'error', failure: { code: 'CHILD_TEST_FAILURE', message: 'child loader failure' } } }
+      : { type: 'finish', reason: { kind: 'stop' } }
   }
 }
 

+ 1 - 0
packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml

@@ -23,6 +23,7 @@
     model: mock-routed
     env:
       DSH_TELEMETRY_DISABLED: '1'
+      DSH_TEST_CHILD_FAILURE: !!js String(process.env.DSH_TEST_CHILD_FAILURE ?? '')
 
 - id: tool-subagent
   name: '@deepseek-ai/dsh-tool-subagent'

+ 64 - 11
packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts

@@ -3,7 +3,8 @@
  * inheritance across the SDK wire. A test-only cordis.yml boots through the
  * Loader, a scripted model selects provider/model/reasoning, tool config adds
  * maxTokens, and a COMPLETE second harness runtime echoes the effective route
- * and cwd. The child's persisted request header must carry all four values.
+ * and cwd. The same path also verifies model-visible child-failure diagnostics
+ * remain separate from partial output.
  */
 
 import { existsSync, realpathSync } from 'node:fs'
@@ -37,13 +38,36 @@ async function sessionEvents(log: string): Promise<SessionEvent[]> {
   return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
 }
 
-describe('SDK subagent dynamic routing through a real cordis.yml', () => {
-  it('runs the selected child route in the parent session workspace', async () => {
-    const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
-    const childPatch = join(childHome, 'child.cordis.yml')
-    await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
-      .replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(childMockPath).href)))
+function toolResultText(events: SessionEvent[]): string {
+  const results = events.filter(event => event.type === 'tool/result')
+  expect(results).toHaveLength(1)
+  return results[0]!.data.message.content[0].content
+    .filter(block => block.type === 'text')
+    .map(block => block.text)
+    .join('')
+}
+
+async function childLaunch(failure = false): Promise<{
+  childHome: string
+  env: Record<string, string>
+}> {
+  const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
+  const childPatch = join(childHome, 'child.cordis.yml')
+  await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
+    .replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(childMockPath).href)))
+  return {
+    childHome,
+    env: {
+      DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
+      DSH_TEST_CHILD_HOME: childHome,
+      ...(failure ? { DSH_TEST_CHILD_FAILURE: '1' } : {}),
+    },
+  }
+}
 
+describe('SDK subagent routing and diagnostics through a real cordis.yml', () => {
+  it('runs the selected child route in the parent session workspace', async () => {
+    const child = await childLaunch()
     let events: SessionEvent[] = []
     let childEvents: SessionEvent[] = []
     let parentResolvedRoutes: string[] = []
@@ -61,8 +85,7 @@ describe('SDK subagent dynamic routing through a real cordis.yml', () => {
         // 30s window.
         processTimeoutMs: 120_000,
         env: {
-          DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
-          DSH_TEST_CHILD_HOME: childHome,
+          ...child.env,
           DSH_TEST_CHILD_DEFAULT_ROUTE: '1',
           DSH_TEST_PARENT_MODEL_RECORD: '.parent-model-routes',
         },
@@ -73,7 +96,7 @@ describe('SDK subagent dynamic routing through a real cordis.yml', () => {
           expect(parentLogs).toHaveLength(1)
           events = await sessionEvents(parentLogs[0] as string)
           // The child runtime persists under its explicit isolated home.
-          const childSessions = join(childHome, 'sessions')
+          const childSessions = join(child.childHome, 'sessions')
           if (!existsSync(childSessions)) {
             const result = events.find(event => event.type === 'tool/result')
             throw new Error(`SDK child persisted no session; parent tool result: ${JSON.stringify(result?.data)}`)
@@ -112,9 +135,39 @@ describe('SDK subagent dynamic routing through a real cordis.yml', () => {
       const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
       expect(childAnswers.length).toBeGreaterThan(0)
     } finally {
-      await rm(childHome, { recursive: true, force: true })
+      await rm(child.childHome, { recursive: true, force: true })
     }
     // 15s of vitest headroom past the subprocess deadline, mirroring
     // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
   }, 135_000)
+
+  it('presents the child error diagnostic separately from partial output', async () => {
+    const child = await childLaunch(true)
+    let events: SessionEvent[] = []
+    try {
+      const { stderr } = await runLoaderSmoke({
+        label: 'dsh-sdk-subagent diagnostic composition smoke',
+        tempDirPrefix: 'dsh-sdk-subagent-diagnostic-e2e-',
+        binScript: driver,
+        libBinScript: driver,
+        configPath,
+        tsconfigPath: repoTsconfig,
+        processTimeoutMs: 120_000,
+        env: child.env,
+        inspect: async (cwd) => {
+          const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
+          expect(parentLogs).toHaveLength(1)
+          events = await sessionEvents(parentLogs[0] as string)
+        },
+      })
+      expect(stderr).not.toContain('UNHANDLED')
+      expect(toolResultText(events)).toBe(
+        'Error: subagent run failed\n'
+        + 'Diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)\n'
+        + 'Partial output before the run ended:\npartial child loader answer',
+      )
+    } finally {
+      await rm(child.childHome, { recursive: true, force: true })
+    }
+  }, 135_000)
 })

+ 319 - 19
packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

@@ -6,7 +6,7 @@
  * quiescent disposal are all exercised end to end. No model, no key.
  */
 
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { existsSync, mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -14,6 +14,12 @@ import { join, relative } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
+import {
+  DeepSeekHarness,
+  HarnessClient,
+  HarnessSession,
+  SdkProtocolError,
+} from '@deepseek-ai/dsh-sdk-client'
 import { createProcessDeepSeekHarness } from '../../../sdk/client/src/api.ts'
 import type { RuntimeProcessOptions } from '../../../sdk/client/src/launch.ts'
 import type { DeepSeekHarnessOptions } from '@deepseek-ai/dsh-sdk-client'
@@ -23,7 +29,7 @@ import {
   DEFAULT_DISPOSE_EOF_GRACE_MS,
   DEFAULT_DISPOSE_GRACE_MS,
   DEFAULT_SHUTDOWN_TIMEOUT_MS,
-  sdkStopReason,
+  sdkChildOutcome,
   startSdkRun,
   internals as runInternals,
   type SdkRunSpec,
@@ -98,6 +104,10 @@ function text(blocks: { type: string; text?: string }[]): string {
   return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
 }
 
+function expectedFailure(fields: string): string {
+  return `Subagent failure (provider: DSH SDK; ${fields})`
+}
+
 /**
  * Poll until `file` exists (the fake touches it once the probed state is
  * reached), so cancel tests wait on a CONDITION rather than an arbitrary
@@ -111,19 +121,32 @@ async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
   }
 }
 
-describe('sdkStopReason', () => {
-  it('maps each child turn-end reason to the harness vocabulary', () => {
-    expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
-    expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
-    expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'user' } })).toBe('aborted')
-    expect(sdkStopReason({ kind: 'error', error: { message: 'x', code: 'UNKNOWN' } })).toBe('error')
-    expect(sdkStopReason({ kind: 'interrupted' })).toBe('error')
-    expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted')
+describe('sdkChildOutcome', () => {
+  it('maps each known child turn-end reason once', () => {
+    expect(sdkChildOutcome({ kind: 'completed' })).toEqual({ stopReason: 'completed' })
+    expect(sdkChildOutcome({ kind: 'max-tokens' })).toEqual({ stopReason: 'max-tokens' })
+    expect(sdkChildOutcome({ kind: 'aborted', reason: { kind: 'user' } })).toEqual({ stopReason: 'aborted' })
+    expect(sdkChildOutcome({ kind: 'aborted', reason: { kind: 'disposed' } })).toEqual({
+      stopReason: 'aborted',
+      diagnostic: expectedFailure('stage: session-run; category: child-disposed'),
+    })
+    expect(sdkChildOutcome({ kind: 'blocked' })).toEqual({ stopReason: 'refusal' })
+    expect(sdkChildOutcome({ kind: 'error', error: { message: 'x', code: 'UNKNOWN' } })).toEqual({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: child-error'),
+    })
+    expect(sdkChildOutcome({ kind: 'interrupted' })).toEqual({ stopReason: 'error' })
   })
 
   it('treats an absent or unknown reason as an error', () => {
-    expect(sdkStopReason(undefined)).toBe('error')
-    expect(sdkStopReason({ kind: 'something-new' } as never)).toBe('error')
+    expect(sdkChildOutcome(undefined)).toEqual({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: missing-terminal'),
+    })
+    expect(sdkChildOutcome({ kind: 'something-new' } as never)).toEqual({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: child-unknown'),
+    })
   })
 })
 
@@ -140,6 +163,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     expect(run.localAgent).toBeUndefined()
     const result = await run.result
     expect(result.stopReason).toBe('completed')
+    expect(result.diagnostic).toBeUndefined()
     expect(text(result.output)).toBe('hello from sdk child')
     // dispose is idempotent (one memoized teardown).
     const disposal = run.dispose()
@@ -284,7 +308,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
   it('maps a max-tokens child turn end', async () => {
     const ctx = await setup({ FAKE_REASON_KIND: 'max-tokens', FAKE_STATUS: 'error' })
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('max-tokens')
+    const result = await run.result
+    expect(result.stopReason).toBe('max-tokens')
+    expect(result.diagnostic).toBeUndefined()
     await run.dispose()
     await ctx.fiber.dispose()
   })
@@ -294,6 +320,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await ctx.subagents.start('dsh-sdk', request())
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: child-error'),
+    )
     expect(text(result.output)).toBe('partial answer')
     await run.dispose()
     await ctx.fiber.dispose()
@@ -310,6 +339,20 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     await ctx.fiber.dispose()
   })
 
+  it('classifies a malformed child turn reason as a protocol failure', async () => {
+    const ctx = await setup({ FAKE_MALFORMED_REASON: '1', FAKE_TEXT: 'partial before bad reason' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+
+    expect(result).toEqual({
+      output: [{ type: 'text', text: 'partial before bad reason' }],
+      diagnostic: expectedFailure('stage: session-run; category: protocol'),
+      stopReason: 'error',
+    })
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
   it('keeps streamed text when the terminal message is an empty usage-only step', async () => {
     // The child streams its answer, then emits an empty-content
     // assistant/message (the harness loop appends one to host usage on a
@@ -327,7 +370,141 @@ describe('dsh-subagent-dsh-sdk provider', () => {
   it('reports a settled-without-turn child as an error', async () => {
     const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('error')
+    expect(await run.result).toMatchObject({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: missing-terminal'),
+    })
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('maps a blocked child turn to the shared refusal stop reason', async () => {
+    const ctx = await setup({ FAKE_REASON_KIND: 'blocked' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('refusal')
+    expect(result.diagnostic).toBeUndefined()
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('aggregates safe initialize and shutdown facts when startup rollback fails', async () => {
+    const rawCleanup = 'shutdown leaked /private/path SECRET_TOKEN'
+    const spy = vi.spyOn(HarnessClient.prototype, 'close').mockImplementation(async function (this: HarnessClient) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const ctx = await setup({ FAKE_MALFORMED: '1' })
+      const error = await ctx.subagents.start('dsh-sdk', request()).catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(AggregateError)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: protocol')}; `
+        + `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+      await ctx.fiber.dispose()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('reports only safe shutdown facts when cancelled startup rollback fails', async () => {
+    const rawCleanup = 'cancelled shutdown leaked SECRET_TOKEN'
+    const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const controller = new AbortController()
+      const pending = startSdkRun(request('p', controller.signal), {
+        profile: 'sdk',
+        patches: [],
+        dshHome: process.cwd(),
+        cwd: process.cwd(),
+        provider: 'p',
+        model: 'm',
+        env: { FAKE_HANG_INIT: '1' },
+        shutdownTimeoutMs: 100,
+        disposeEofGraceMs: 100,
+        disposeGraceMs: 100,
+      })
+      controller.abort()
+      const error = await pending.catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(AggregateError)
+      expect((error as AggregateError).errors).toHaveLength(1)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('keeps an initialize failure authoritative when a later abort flag is already set', async () => {
+    const rawFailure = new SdkProtocolError('scripted initialize rejection')
+    const start = vi.spyOn(DeepSeekHarness.prototype, 'start').mockRejectedValue(rawFailure)
+    const close = vi.spyOn(DeepSeekHarness.prototype, 'close').mockResolvedValue()
+    try {
+      const controller = new AbortController()
+      const pending = startSdkRun(request('p', controller.signal), {
+        profile: 'sdk',
+        patches: [],
+        dshHome: process.cwd(),
+        cwd: process.cwd(),
+        provider: 'p',
+        model: 'm',
+        env: {},
+        shutdownTimeoutMs: 100,
+        disposeEofGraceMs: 100,
+        disposeGraceMs: 100,
+      })
+      controller.abort()
+      await expect(pending).rejects.toThrow(
+        `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: protocol')}`,
+      )
+      expect(close).not.toHaveBeenCalled()
+    } finally {
+      start.mockRestore()
+      close.mockRestore()
+    }
+  })
+
+  it('preserves a disposed child cancellation without treating it as local cancellation', async () => {
+    const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'disposed' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: child-disposed'),
+    )
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps an ordinary child abort diagnostic-free', async () => {
+    const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'user' })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBeUndefined()
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('uses a fixed fallback for an unknown child terminal reason', async () => {
+    const rawReason = 'private/path/SECRET_TOKEN'
+    const ctx = await setup({ FAKE_REASON_KIND: rawReason })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: child-unknown'),
+    )
+    expect(result.diagnostic).not.toContain(rawReason)
     await run.dispose()
     await ctx.fiber.dispose()
   })
@@ -339,6 +516,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     controller.abort('test')
     const result = await run.result
     expect(result.stopReason).toBe('aborted')
+    expect(result.diagnostic).toBeUndefined()
     // The hung child streamed nothing, so the aborted result has no output.
     expect(result.output).toEqual([])
     await run.dispose()
@@ -386,11 +564,74 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await ctx.subagents.start('dsh-sdk', request())
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: protocol'),
+    )
     expect(result.output).toEqual([])
     await run.dispose()
     await ctx.fiber.dispose()
   })
 
+  it('preserves partial output while hiding a transport error stderr tail', async () => {
+    const stderr = 'private/path SECRET_TOKEN must remain Host-only'
+    const ctx = await setup({
+      FAKE_EXIT_DURING_PROMPT: '1',
+      FAKE_TEXT: 'partial before transport exit',
+      FAKE_STDERR: stderr,
+    })
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    const result = await run.result
+    expect(result.stopReason).toBe('error')
+    expect(result.output).toEqual([{ type: 'text', text: 'partial before transport exit' }])
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: transport'),
+    )
+    expect(result.diagnostic).not.toContain(stderr)
+    await run.dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('uses a fixed unknown category for an untyped SDK exception', async () => {
+    const rawMessage = 'unknown SDK failure at /private/path SECRET_TOKEN'
+    const spy = vi.spyOn(HarnessSession.prototype, 'run')
+      .mockRejectedValue(new Error(rawMessage))
+    try {
+      const ctx = await setup()
+      const run = await ctx.subagents.start('dsh-sdk', request())
+      const result = await run.result
+      expect(result.diagnostic).toBe(
+        expectedFailure('stage: session-run; category: unknown'),
+      )
+      expect(result.diagnostic).not.toContain(rawMessage)
+      await run.dispose()
+      await ctx.fiber.dispose()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('keeps child diagnostics isolated across concurrent runs', async () => {
+    const start = (reason: 'error' | 'unknown-reason') => startSdkRun(request(), {
+      profile: 'sdk',
+      patches: [],
+      dshHome: process.cwd(),
+      cwd: process.cwd(),
+      provider: 'p',
+      model: 'm',
+      env: { FAKE_REASON_KIND: reason },
+      shutdownTimeoutMs: 100,
+      disposeEofGraceMs: 200,
+      disposeGraceMs: 200,
+    })
+    const [errored, unknown] = await Promise.all([start('error'), start('unknown-reason')])
+    const [errorResult, unknownResult] = await Promise.all([errored.result, unknown.result])
+    expect(errorResult.diagnostic).toContain('category: child-error')
+    expect(errorResult.diagnostic).not.toContain('child-unknown')
+    expect(unknownResult.diagnostic).toContain('category: child-unknown')
+    expect(unknownResult.diagnostic).not.toContain('child-error')
+    await Promise.all([errored.dispose(), unknown.dispose()])
+  })
+
   it('dispose cancels a hung child locally and reaps it', async () => {
     const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
     const run = await ctx.subagents.start('dsh-sdk', request())
@@ -426,14 +667,42 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     }
   })
 
+  it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => {
+    const ctx = await setup()
+    const controller = new AbortController()
+    controller.abort()
+    const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
+    await expect(ctx.subagents.start('dsh-sdk', {
+      label: 'p',
+      prompt: [{ type: 'text' as const, text: 'p' }],
+      parent,
+      signal: controller.signal,
+    })).rejects.toThrow('subagent request was aborted before the SDK child started')
+    await ctx.fiber.dispose()
+  })
+
   it('rejects after reaping when the child dies before the handshake', async () => {
-    const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'scripted boot failure' })
+    const rawStderr = 'scripted boot failure at /private/path SECRET_TOKEN'
+    const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: rawStderr })
     const failure = await ctx.subagents.start('dsh-sdk', request()).then(
       () => { throw new Error('start unexpectedly succeeded') },
       (error: unknown) => error,
     )
-    expect(String(failure)).toContain('exit code: 3')
-    expect(String(failure)).toContain('scripted boot failure')
+    expect(String(failure)).toBe(
+      `SdkRunFailure: subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: transport')}`,
+    )
+    expect(String(failure)).not.toContain(rawStderr)
+    await ctx.fiber.dispose()
+  })
+
+  it.each([
+    [{ FAKE_MALFORMED: '1' }, 'protocol'],
+    [{ FAKE_INIT_ERROR: '1' }, 'protocol'],
+  ] as const)('rejects an initialize failure with safe %s facts', async (env, category) => {
+    const ctx = await setup({ ...env })
+    await expect(ctx.subagents.start('dsh-sdk', request())).rejects.toThrow(
+      `subagent-dsh-sdk: ${expectedFailure(`stage: initialize; category: ${category}`)}`,
+    )
     await ctx.fiber.dispose()
   })
 
@@ -480,6 +749,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const run = await startSdkRun(request(), spec)
     const result = await run.result
     expect(result.stopReason).toBe('error')
+    expect(result.diagnostic).toBe(
+      expectedFailure('stage: session-run; category: protocol'),
+    )
     expect(seen).toHaveLength(1)
     await run.dispose()
   })
@@ -489,13 +761,39 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     const warnings: string[] = []
     ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
     const run = await ctx.subagents.start('dsh-sdk', request())
-    expect((await run.result).stopReason).toBe('error')
+    expect(await run.result).toMatchObject({
+      stopReason: 'error',
+      diagnostic: expectedFailure('stage: session-run; category: protocol'),
+    })
     expect(warnings).toHaveLength(1)
     expect(warnings[0]).toContain('subagent-dsh-sdk "dsh-sdk": child run failed (error)')
     await run.dispose()
     await ctx.fiber.dispose()
   })
 
+  it('wraps a shutdown rejection with safe facts after the runtime is reaped', async () => {
+    const rawCleanup = 'shutdown failed at /private/path SECRET_TOKEN'
+    const ctx = await setup()
+    const run = await ctx.subagents.start('dsh-sdk', request())
+    await run.result
+    const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) {
+      spy.mockRestore()
+      await this.close()
+      throw new Error(rawCleanup)
+    })
+    try {
+      const error = await run.dispose().catch((cause: unknown) => cause)
+      expect(error).toBeInstanceOf(Error)
+      expect((error as Error).message).toBe(
+        `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`,
+      )
+      expect((error as Error).message).not.toContain(rawCleanup)
+    } finally {
+      spy.mockRestore()
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentRuntime)
@@ -645,7 +943,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
     await expect(ctx.subagents.start('dsh-sdk', {
       label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
     }))
-      .rejects.toThrow('no working directory for the child')
+      .rejects.toThrow(
+        `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: configuration')}`,
+      )
     await ctx.fiber.dispose()
   })
 

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