Bladeren bron

Merge origin/master into worktree/web-sidebar-terminal

Yichen Jiang 1 week geleden
bovenliggende
commit
6e945def2f
100 gewijzigde bestanden met toevoegingen van 648 en 401 verwijderingen
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
  11. 9 1
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  12. 9 1
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml
  14. 1 1
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md
  15. 1 1
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml
  17. 1 1
      .agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md
  18. 1 1
      .agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  20. 1 1
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  21. 1 1
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
  23. 1 1
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
  24. 1 1
      .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
  25. 2 2
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml
  26. 1 1
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md
  27. 1 1
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md
  28. 2 2
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml
  29. 1 1
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
  30. 1 1
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md
  31. 2 2
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
  32. 13 13
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  33. 13 13
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  34. 2 2
      .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml
  35. 1 1
      .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md
  36. 1 1
      .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md
  37. 2 2
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
  38. 1 1
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
  39. 1 1
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
  40. 2 2
      .agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml
  41. 1 1
      .agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md
  42. 1 1
      .agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md
  43. 2 2
      .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
  44. 1 1
      .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
  45. 1 1
      .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
  46. 2 2
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml
  47. 2 2
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md
  48. 2 2
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md
  49. 1 1
      AGENTS.md
  50. 2 2
      apps/web/tests/preview-boot.e2e.ts
  51. 14 5
      apps/web/tests/sidebar-right.e2e.ts
  52. 1 1
      docs/AGENTS.md
  53. 2 2
      docs/config-catalog.i18n.yaml
  54. 2 2
      docs/config-catalog.md
  55. 2 2
      docs/config-catalog.zh.md
  56. 2 2
      docs/cookbook/adding-a-session-format-version.i18n.yaml
  57. 14 14
      docs/cookbook/adding-a-session-format-version.md
  58. 14 14
      docs/cookbook/adding-a-session-format-version.zh.md
  59. 2 2
      docs/deepseek-llm-api-wire-extensions.i18n.yaml
  60. 2 2
      docs/deepseek-llm-api-wire-extensions.md
  61. 2 2
      docs/deepseek-llm-api-wire-extensions.zh.md
  62. 6 0
      docs/session-format-status.i18n.yaml
  63. 47 0
      docs/session-format-status.md
  64. 47 0
      docs/session-format-status.zh.md
  65. 2 2
      docs/subsystems/persistence.i18n.yaml
  66. 2 2
      docs/subsystems/persistence.md
  67. 2 2
      docs/subsystems/persistence.zh.md
  68. 2 2
      docs/subsystems/session.i18n.yaml
  69. 2 2
      docs/subsystems/session.md
  70. 2 2
      docs/subsystems/session.zh.md
  71. 2 2
      docs/subsystems/workspace.i18n.yaml
  72. 18 20
      docs/subsystems/workspace.md
  73. 18 20
      docs/subsystems/workspace.zh.md
  74. 2 2
      docs/testing.i18n.yaml
  75. 1 1
      docs/testing.md
  76. 1 1
      docs/testing.zh.md
  77. 2 2
      packages/api/README.i18n.yaml
  78. 1 1
      packages/api/README.md
  79. 1 1
      packages/api/README.zh.md
  80. 2 2
      packages/api/workspace-files/README.i18n.yaml
  81. 7 7
      packages/api/workspace-files/README.md
  82. 7 7
      packages/api/workspace-files/README.zh.md
  83. 1 1
      packages/api/workspace-files/package.json
  84. 2 2
      packages/api/workspace-files/src/changes.ts
  85. 96 50
      packages/api/workspace-files/src/index.ts
  86. 3 3
      packages/api/workspace-files/src/types.ts
  87. 3 3
      packages/api/workspace-files/tests/changes.spec.ts
  88. 12 7
      packages/api/workspace-files/tests/harness.ts
  89. 13 13
      packages/api/workspace-files/tests/list.spec.ts
  90. 19 19
      packages/api/workspace-files/tests/read-all.spec.ts
  91. 21 21
      packages/api/workspace-files/tests/read-bytes.spec.ts
  92. 34 34
      packages/api/workspace-files/tests/read.spec.ts
  93. 75 0
      packages/api/workspace-files/tests/scope.spec.ts
  94. 13 13
      packages/api/workspace-files/tests/stat.spec.ts
  95. 3 3
      packages/api/workspace-files/tsconfig.host.json
  96. 2 2
      packages/core/session/README.i18n.yaml
  97. 1 1
      packages/core/session/README.md
  98. 1 1
      packages/core/session/README.zh.md
  99. 1 1
      packages/core/session/src/types.ts
  100. 2 2
      packages/core/system-prompt/README.i18n.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-14-session-persistence.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/architecture/2026-06-14-session-persistence.md
-2026-06-14-session-persistence.md: 55c1bbab94bb7854fd6bbcaace56c30dbcdb01dc
-2026-06-14-session-persistence.zh.md: 16546ab61773da47064de8388803e92c8b454eae
+2026-06-14-session-persistence.md: d79975e1fedb6efcd0e4ea83bc799bb158082e41
+2026-06-14-session-persistence.zh.md: f70b8005a254cc6a9ea8ada31c32e5552ee0c8b2

+ 3 - 3
.agents/notes/implemented/architecture/2026-06-14-session-persistence.md

@@ -15,11 +15,11 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
 Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic:
 
 1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`flush`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
-2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
+2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. The current format writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
 
 Key durable, contested choices:
 
-- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
+- **The canonical durable log persists every current `SessionEvent` losslessly.** One `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
 - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../../../../packages/session/session-checkpoint-policy/README.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable.
 - **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
 - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md).)
@@ -29,7 +29,7 @@ Key durable, contested choices:
 
 Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
 
-Format versioning: the header carries a `version`; handles expose only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before returning a handle; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.
+Format versioning: the header carries a `version`; handles expose only the logical format selected by `SESSION_FORMAT_VERSION` ([version authority](../../../../docs/session-format-status.md)). JSONL event-body reads compose the complete static adjacent migration chain before returning a handle; each edge owns its historical transformations. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.
 
 ## Consequences
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md

@@ -15,11 +15,11 @@ Status: implemented
 持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
 
 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`flush`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`([基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md))。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
-2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
+2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前格式每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
 
 长期有效、存在争议的关键选择:
 
-- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。
+- **规范持久日志无损保留每个当前 `SessionEvent`。** 一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。
 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../../../../packages/session/session-checkpoint-policy/README.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
 - **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md)。)
@@ -29,7 +29,7 @@ Status: implemented
 
 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
 
-格式版本控制:header 携带 `version`;句柄只暴露 `SESSION_FORMAT_VERSION = 2`。JSONL 的事件正文读取会在返回句柄前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条边负责有界 legacy normalization,第二条边负责 Assistant stream 嵌入与密集引用重映射。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。
+格式版本控制:header 携带 `version`;句柄只暴露由 `SESSION_FORMAT_VERSION` 选定的逻辑格式([版本真源](../../../../docs/session-format-status.zh.md))。JSONL 的事件正文读取会在返回句柄前组合完整的静态相邻迁移链;每条迁移边拥有自身的历史转换。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.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/architecture/2026-07-14-provider-routed-llm-adapters.md
-2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1
-2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac
+2026-07-14-provider-routed-llm-adapters.md: 1001b10e18837399b41578658ffe62065e294ba8
+2026-07-14-provider-routed-llm-adapters.zh.md: c5bff0ef407c8a2c22e8cad7e7d32c58c8b0e2da

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md

@@ -54,7 +54,7 @@ Compaction configuration gains `summarizationProvider` beside `summarizationMode
 
 The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter.
 
-Current v1 seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication.
+Current seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication.
 
 ## Alternatives considered
 
@@ -78,7 +78,7 @@ Current v1 seed/load validation rejects request headers and assistant messages t
 - pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy.
 - `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support.
 - Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state.
-- Current v1 Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity.
+- Current Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity.
 
 ## Testing
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md

@@ -54,7 +54,7 @@ pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充
 
 JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
 
-当前 v1 的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。
+当前的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。
 
 ## 考虑过的替代方案
 
@@ -78,7 +78,7 @@ JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方
 - pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。
 - pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。
 - 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。
-- 当前 v1 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。
+- 当前 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。
 
 ## 测试
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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/architecture/2026-07-30-session-end-seed-log-boundary.md
-2026-07-30-session-end-seed-log-boundary.md: aeec2a36d0b1e498591ef509e2e9164f586ed60c
-2026-07-30-session-end-seed-log-boundary.zh.md: ceba46a474c402230dbf215a2d53a41d3c027fc2
+2026-07-30-session-end-seed-log-boundary.md: 76f8904f75f6c4b5a1a6acab8d69ef2290be718e
+2026-07-30-session-end-seed-log-boundary.zh.md: 4310654c841305a7f0de2f46434d0f839085c482

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md

@@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat
 
 Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert that capture begins with the current lifecycle's newly appended boundary and excludes the constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.
 
-`session/end-seed` joins the on-disk vocabulary. Current v1 requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read.
+`session/end-seed` joins the on-disk vocabulary. The current format requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read.
 
 The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compaction/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md

@@ -50,6 +50,6 @@ Status: implemented
 
 代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言捕获从当前生命周期新追加的边界开始,并排除 constructor seed;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。
 
-`session/end-seed` 加入了落盘词汇表。当前 v1 要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。
+`session/end-seed` 加入了落盘词汇表。当前格式要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。
 
 [排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 `compaction/start` 与最新 end-seed,只把位于该边界之后的 start 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 0f7f70b5ad6ecb2445729b1aa61fb3b295fc4ddb
-2026-08-10-session-log-version-mechanism.zh.md: a6d58505ad9fdb6068a1afe48f7250f020d20a9e
+2026-08-10-session-log-version-mechanism.md: 513b126d3b00841f71893715cc296cb67619543e
+2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0

+ 9 - 1
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md

@@ -16,7 +16,13 @@ Session logs must be upgradable after release, and the runtime that ships first
 
 **Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise.
 
-**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
+**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
+
+### Writer and publication authority
+
+`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers.
+
+The [documentation-standard check](../../../../scripts/doc-standard.spec.ts) validates record structure, bilingual equality, evidence-link consistency, and the local release/writer ordering without network access. It proves internal consistency, not publication or freshness. The release operator verifies publication and updates the record after a higher format ships, as required by the [release process](../process/2026-08-10-npm-release-sequences.md). This keeps compatibility review independent of credentials and GitHub availability while making the manual freshness obligation explicit.
 
 ## Consequences
 
@@ -28,3 +34,5 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path
 - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
 - **Migrating during header-only listing** — makes cheap inventory mutate storage and requires event bodies to compute facts that a header cannot prove. Listing returns descriptors; event-body reads own publication.
 - **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint.
+- **Duplicate release flags or runtime status services** — introduce another mutable authority for a maintainer fact that does not control Session execution. The writer constant and publication record suffice.
+- **Network-dependent documentation gates or publication automation** — network queries would couple local documentation checks to credentials and GitHub availability; a runtime service or publication workflow change is unnecessary for record consistency. Publication verification remains an explicit release-operator obligation.

+ 9 - 1
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md

@@ -16,7 +16,13 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
 
 **读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。
 
-**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
+**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
+
+### 写入器与发布真源
+
+`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。
+
+[文档标准检查](../../../../scripts/doc-standard.spec.ts)在不访问网络的情况下,校验记录结构、双语一致性、证据链接一致性及本地发布版本与写入器版本的大小关系。它证明内部一致性,而非发布事实或记录新鲜度。[发布流程](../process/2026-08-10-npm-release-sequences.zh.md)要求发布操作者在更高格式交付后核实发布并更新记录。这让兼容性评审不依赖凭据与 GitHub 可用性,同时明确人工维护新鲜度的义务。
 
 ## 影响
 
@@ -28,3 +34,5 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径
 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
 - **在仅 header 列表期间迁移**:让便宜清单改变存储,而且需要读取事件正文才能计算 header 无法证明的事实。列表返回 descriptor,事件正文读取负责发布。
 - **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。
+- **重复发布标记或运行时状态服务**:为不控制 Session 执行的维护信息增加另一个可变真源。写入器常量与发布记录已经足够。
+- **依赖网络的文档门禁或发布自动化**:网络查询会把本地文档检查耦合到凭据与 GitHub 可用性;记录一致性不需要运行时服务或发布工作流变更。核实发布仍是发布操作者的明确义务。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.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/architecture/2026-08-27-handle-based-session-persistence.md
-2026-08-27-handle-based-session-persistence.md: 9b5ff5d46444ac924859c4121abc1cf5d1538485
-2026-08-27-handle-based-session-persistence.zh.md: c9230c899141f4ab9979f93256da42a3aaae0984
+2026-08-27-handle-based-session-persistence.md: e2f07856b7ef8ffd8180ed7ff515210c95dde29b
+2026-08-27-handle-based-session-persistence.zh.md: 472a1024887ecca67245de27f52c68ec0b34caa1

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md

@@ -32,7 +32,7 @@ The previous persistence seam owned far more than storage. A shared coordinator
 
 ## Consequences
 
-Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0.
+Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). Handle ownership does not change the serialized Session representation.
 
 ## Related
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md

@@ -32,7 +32,7 @@ Status: implemented
 
 ## 后果
 
-恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0
+恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。句柄所有权不改变序列化的 Session 表示
 
 ## 相关
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.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/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md
-2026-08-31-alpha-historical-unknown-event-refusal.md: 58690e30281c1f5e10f85726c1f1e50fd4664fe9
-2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 73ab2ca47ab3f68b11e71ffec09287253215aa53
+2026-08-31-alpha-historical-unknown-event-refusal.md: c63b36f63206e0992416239483d908b0645a98c2
+2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 231c46c11486b05a69a200b857a0ea6df35b6e79

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md

@@ -14,7 +14,7 @@ Silently copying such an event can leave stale numeric references after a later
 
 The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. Merge-extensible nested discriminants remain part of that explicit policy: unknown content-block types, message-source kinds, assistant finish-reason kinds, and turn-ending reason kinds are preserved as owner-opaque JSON, while known arms receive structural validation. The diagnostic names the event type, its sequence number, and the unchanged source generation.
 
-The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. New v1 external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge.
+The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. Native current-format external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge.
 
 Every first-party source event type has an executable disposition and target validator in the edge package. The catalog is build-static and profile-independent, so mounting or omitting the producer plugin cannot change whether an old artifact migrates.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。可合并扩展的嵌套判别字段同样属于这项显式策略:未知 content-block type、message-source kind、assistant finish-reason kind 与 turn-ending reason kind 会作为 owner 不透明 JSON 保留,已知分支则接受结构校验。诊断会点名事件类型、序号和保持不变的源 generation。
 
-该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此新的 v1 外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。
+该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此原生当前格式的外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。
 
 每个第一方源事件类型都在迁移边包中拥有可执行 disposition 与目标 validator。catalog 在构建时静态确定且与 profile 无关,因此 producer 插件是否挂载不会改变旧产物能否迁移。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582
-2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5
+2026-08-31-released-session-format-migrations.md: d566459af64f4177ed0135813e75aaf77480823c
+2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -76,7 +76,7 @@ Preset renames cover the creation header and every selection event because the l
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
 
-All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
+The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs.
 
 The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -76,7 +76,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
 
-所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际
+[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
 
 [已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-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 .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
-2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280
-2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc
+2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848
+2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md

@@ -46,4 +46,4 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
 
 Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
 
-Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs.
+Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs.

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md

@@ -46,4 +46,4 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
 
 Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
 
-不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan
+不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.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/architecture/2026-09-02-system-prompt-as-surface-node.md
-2026-09-02-system-prompt-as-surface-node.md: dc0d22b2fb927ad288415346bea9d0c2793cf000
-2026-09-02-system-prompt-as-surface-node.zh.md: 368684d85cb7ddf5d0be63bce905ce48e86cb49f
+2026-09-02-system-prompt-as-surface-node.md: 1500fd2350f02ab5d8f203c0d62b98832c6c5de5
+2026-09-02-system-prompt-as-surface-node.zh.md: 306f347092caa3e9daf2494fa26d290b1c1ab9a7

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md

@@ -60,7 +60,7 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r
 
 The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log.
 
-The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4.
+The [released-format policy](2026-08-31-released-session-format-migrations.md) preserves each released conversion’s semantics; an existing target-format generation does not rerun its incoming edge. Projection-cache versions are independent of Session format versions.
 
 The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md

@@ -60,7 +60,7 @@ Status: implemented
 
 [V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认。
 
-[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4
+[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保留每条已发布转换的语义;已有目标格式代际不会重跑其入边。投影缓存版本独立于 Session 格式版本
 
 [规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.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/architecture/2026-09-05-read-only-session-migration-preparation.md
-2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf
-2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3
+2026-09-05-read-only-session-migration-preparation.md: 081044e34d5071ba242792c588f43a3b83adddd5
+2026-09-05-read-only-session-migration-preparation.zh.md: 7406e7cf31112df6c9f6bb0d454f38d0cf7eb6a4

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md

@@ -65,7 +65,7 @@ Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` disc
 
 `SessionHandle.read()` reports whether its event values are detached or shared-frozen. The JSONL backend deep-freezes each decoded event graph once before memoization and creates the `shared-frozen` result there; later reads and slices preserve that producer-established state even when the slice is empty. `readColdSessionLog()` combines those values with locally owned interrupted-turn closers and passes the `eventState` through `SessionObservationReader`; `Session.fromRestore()` validates and adopts the seed without copying or freezing. Ordinary create and fork seeds keep their defensive snapshot path.
 
-Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-v2 files rely on their writer; consumers that expand a compact stream validate its records when they read it.
+Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-format files rely on their writer; consumers that expand a compact stream validate its records when they read it.
 
 ### Read handle transition
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md

@@ -65,7 +65,7 @@ interface MigrationPreparation {
 
 `SessionHandle.read()` 会报告 event value 是 detached 还是 shared-frozen。JSONL backend 在 memo 化前只对每个已解码 event graph 深度冻结一次,并在该处构造 `shared-frozen` 结果;后续读取和 slice 即使为空也会保留生产者建立的状态。`readColdSessionLog()` 将这些 event 与本地独占的 interrupted-turn closer 组合,并通过 `SessionObservationReader` 继续传递 `eventState`;`Session.fromRestore()` 只校验和接管 seed,不再复制或冻结。普通 create 与 fork seed 继续使用 defensive snapshot 路径。
 
-Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有 current-v2 文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。
+Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有当前格式文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。
 
 ### Read handle 切换
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.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/architecture/2026-09-05-workspace-files-service.md
-2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85
-2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443
+2026-09-05-workspace-files-service.md: b2dbdfc99388d8c2f18991a7704599d2d95f070b
+2026-09-05-workspace-files-service.zh.md: d860d220af50a24a6e0f40b640d0a8fb534c4741

+ 13 - 13
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md

@@ -20,23 +20,23 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's
 
 | Face | Package | Files | Depends on |
 |---|---|---|---|
-| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` |
-| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
+| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-session`, `dsh-session-persistence` |
+| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-session/types`, `dsh-client-resources`, `dsh-client-ui-slots`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
 
 `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
 
 ### The `workspaceFiles` Remote namespace
 
-Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them:
+Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them:
 
 ```ts ignore-check
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
 - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
@@ -57,7 +57,7 @@ Two path vocabularies leave the service, and each method uses exactly one. `read
 `read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
 
 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
-2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
+2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
 3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
 
@@ -131,7 +131,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 ## Consequences
 
-- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate.
+- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. Header-only Session scope lets ordinary, subagent, live, and cold Sessions resolve their own relative paths without an Agent lifecycle or parent fallback.
 - A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
 - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks.
 - Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key.
@@ -142,7 +142,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 ## Testing
 
-Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
+Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
 
 ## Deferred
 

+ 13 - 13
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md

@@ -20,23 +20,23 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 
 | 面 | 包 | 文件 | 依赖 |
 |---|---|---|---|
-| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` |
-| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
+| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-session`、`dsh-session-persistence` |
+| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-session/types`、`dsh-client-resources`、`dsh-client-ui-slots`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
 
 `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
 
 ### `workspaceFiles` Remote 命名空间
 
-每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明:
+每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明:
 
 ```ts ignore-check
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
@@ -57,7 +57,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 `read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
 
 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
-2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
+2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
 3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
 
@@ -131,7 +131,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 ## Consequences
 
-- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。
+- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。header-only Session scope 让普通、subagent、live 与 cold Session 都能解析自己的相对路径,不需要 Agent 生命周期,也不回退父 Session。
 - 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。
 - 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。
@@ -142,7 +142,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 ## Testing
 
-`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、带包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes``packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
+`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
 
 ## Deferred
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.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-12-subagent-persona-tool-filter-and-depth.md
-2026-07-12-subagent-persona-tool-filter-and-depth.md: 511e340c81b811ebcaaea48946c377c99c53da54
-2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 8a213f33a9b70a9bdec6f23b5bec4db5d94f110f
+2026-07-12-subagent-persona-tool-filter-and-depth.md: f848b92a7a5647995330e2bf26796dde1b43a3f8
+2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 3360b27c9feb5950cc3f338c89e48a4c902e2ac8

+ 1 - 1
.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md

@@ -34,7 +34,7 @@ This uses the normal system-prompt registration mechanism rather than a second p
 
 ### Tool filtering is one live global-view rule
 
-The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance.
+The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections remain owned by their plugins. The filesystem, search, and web tool plugins use the existing `PromptSection.text({ scope })` callback and `ctx.tools.get(name, scope)` to omit guidance for unavailable tools and select applicable cross-tool text. This keeps the original wording and ordering for a supported tool set and works for any agent scope, including underlying PTC capabilities whose wire presentation is `run_code`. It adds no section-ownership metadata or assembly pass; unrelated static prose is not automatically rewritten by `restrict()`.
 
 Resolution follows these rules:
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md

@@ -36,7 +36,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
 
 ### 工具过滤是一条作用于实时全局视图的规则
 
-工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本
+工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落仍由各插件负责。文件系统、搜索和 Web 工具插件使用已有的 `PromptSection.text({ scope })` 回调与 `ctx.tools.get(name, scope)`,省略不可用工具的指导,并选择适用的跨工具文本。这会保留受支持工具集合下的原有措辞与顺序,适用于任意 agent scope,也包括协议呈现为 `run_code` 的底层 PTC 能力。该方式不新增段落归属元数据或组装步骤;`restrict()` 不会自动改写其他静态文字
 
 解析遵循以下规则:
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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/process/2026-08-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5
-2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c
+2026-08-10-npm-release-sequences.md: 19e8d8db8550816f542111a9b831a3694a080de5
+2026-08-10-npm-release-sequences.zh.md: d057d77adbf35c20fc260203d3ef0db92e30f6b7

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -117,7 +117,7 @@ The dsh family applies the repository's publication payload policy, which reject
 
 The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building.
 
-`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state.
+`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state. After a dsh publication succeeds, the release operator verifies its Session writer against the [release record](../../../../docs/session-format-status.md#updating-the-record) and updates that record when a higher Session format has shipped.
 
 A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so the dsh `pack` job packs the vendored family for verification while publishing only the dsh set. The publish workflow (`release-publish.yml`) repacks the current tree and publishes only the dsh set.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -117,7 +117,7 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v
 
 `pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
 
-`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。
+`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。dsh 发布成功后,发布操作者按[发布记录](../../../../docs/session-format-status.zh.md#updating-the-record)核实其 Session 写入器;若交付了更高的 Session 格式,则更新该记录。
 
 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族。
 

+ 2 - 2
.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.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/simplification/2026-06-20-collapse-trace-only-session-events.md
-2026-06-20-collapse-trace-only-session-events.md: e7c40cbde6dd666542bb4022064a1be97b0e0ce8
-2026-06-20-collapse-trace-only-session-events.zh.md: b2c062fc8138a120da9467250b50772083adca75
+2026-06-20-collapse-trace-only-session-events.md: f83d93a899d59eb3ebb3f22f36e32aa7865bf902
+2026-06-20-collapse-trace-only-session-events.zh.md: e53544aed0d3b88c7eb85cd1e9a4f12af7686256

+ 1 - 1
.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md

@@ -27,7 +27,7 @@ The user conversation log contains what is needed to render, resume, audit, and
 
 ## Verification
 
-`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; the frozen v0 codec and identity migration preserve this released representation into current v1; and the docs state where token usage and operational errors are observed.
+`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; the frozen v0 codec and identity migration preserve this released representation into released v1; and the docs state where token usage and operational errors are observed.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md

@@ -27,7 +27,7 @@ Status: implemented
 
 ## 验证
 
-`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 `turn/end { kind: 'error', step, message, code? }` 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;冻结的 v0 codec 与恒等迁移会把该已发布表示保留到当前 v1;文档说明了 token 用量和运行错误的观测位置。
+`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 `turn/end { kind: 'error', step, message, code? }` 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;冻结的 v0 codec 与恒等迁移会把该已发布表示保留到已发布 v1;文档说明了 token 用量和运行错误的观测位置。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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/testing/2026-06-19-acp-snapshot-tests.md
-2026-06-19-acp-snapshot-tests.md: da02393fef1641dc3b20fd3666c1d24ea91aa7f3
-2026-06-19-acp-snapshot-tests.zh.md: 76f721d78850aff837f9851ffa11ae21cabf4488
+2026-06-19-acp-snapshot-tests.md: d6fd6f74342fda3e1f3c686376f521bf82546e85
+2026-06-19-acp-snapshot-tests.zh.md: dfd09f9d9036763c5fd98393f078bf1772aa0beb

+ 1 - 1
.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md

@@ -20,7 +20,7 @@ The [session-log snapshot corpus decision](2026-08-24-session-log-snapshot-corpu
 
 Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. The compact streams embedded in `assistant/message` and `assistant/attempt` reproduce model attempts; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output.
 
-Every current v2 session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation.
+Every current session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation.
 
 ### Replay derives the model script from the log
 

+ 1 - 1
.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/message` 与 `assistant/attempt` 中嵌入的紧凑 stream 会复现模型 attempt;工具、message 与 boundary event 捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。
 
-每个当前 v2 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。
+每个当前 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。
 
 ### 回放从日志推导模型脚本
 

+ 2 - 2
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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/testing/2026-08-24-session-log-snapshot-corpus.md
-2026-08-24-session-log-snapshot-corpus.md: ebcd9709f9cd17ad288d787a13ca66efee5fcc42
-2026-08-24-session-log-snapshot-corpus.zh.md: 8d0614e150c927b491784b51a166e752a7718dae
+2026-08-24-session-log-snapshot-corpus.md: a9001c2eabd610e08cfb1177f2b1339f306320af
+2026-08-24-session-log-snapshot-corpus.zh.md: d02c798af23a205993e92ed72a0f3a162f8bdded

+ 2 - 2
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md

@@ -22,7 +22,7 @@ Fixture decoding and comparison depend only on the selected JSONL content; filen
 
 Headless stderr reconstruction expands embedded reasoning from both `assistant/message` and log-only `assistant/attempt` settlements, so failed or retried reasoning remains part of the projected process output.
 
-Each parent or child role uses `session[.<ordinal>][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The v2 corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor.
+Each parent or child role uses `session[.<ordinal>][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the v1→v2 structural edge within the complete migration chain. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor.
 
 Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes.
 
@@ -30,7 +30,7 @@ Every existing ACP scenario receives a behavior-preserving destination. Ordinary
 
 Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism.
 
-Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no canonical V3 sibling that could displace them. Separate `writer.expected.jsonl` and `writer.<ordinal>.expected.jsonl` files pin exact normalized native V3 parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations.
+Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no newer canonical sibling that could displace them. Separate `writer.expected.jsonl` and `writer.<ordinal>.expected.jsonl` files pin exact normalized native current-format parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md

@@ -22,7 +22,7 @@ Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 invento
 
 Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 `assistant/attempt` settlement 中嵌入的 reasoning,因此失败或重试尝试的 reasoning 仍属于进程输出投影。
 
-每个 parent 或 child 角色都使用 `session[.<ordinal>][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。v2 语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。
+每个 parent 或 child 角色都使用 `session[.<ordinal>][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖完整迁移链中的 v1→v2 结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。
 
 场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。
 
@@ -30,7 +30,7 @@ Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的
 
 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。
 
-当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的规范 V3 同角色文件。单独的 `writer.expected.jsonl` 与 `writer.<ordinal>.expected.jsonl` 文件固定精确的规范化原生 V3 父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。
+当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的更新的规范同角色文件。单独的 `writer.expected.jsonl` 与 `writer.<ordinal>.expected.jsonl` 文件固定精确的规范化原生当前格式的父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。
 
 ## Alternatives considered
 

+ 1 - 1
AGENTS.md

@@ -4,7 +4,7 @@ DeepSeek Harness is an all-plugin Cordis agent harness. Read [docs/architecture.
 
 ## Pre-stable APIs and released Session data
 
-Public APIs are pre-stable; update every consumer. Released Session JSONL follows [adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md): body reads may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite domains use monotonic `SCHEMA_VERSION`.
+Public APIs are pre-stable; update every consumer. [Session version/status](docs/session-format-status.md) defines the authorities. [Adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite uses monotonic `SCHEMA_VERSION`.
 
 **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)).
 

+ 2 - 2
apps/web/tests/preview-boot.e2e.ts

@@ -398,8 +398,8 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
     await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
 
     expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
-    await page.getByText('PREVIEW.md', { exact: true }).waitFor()
-    await page.getByText('src/preview.ts', { exact: true }).waitFor()
+    await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor()
+    await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor()
     await page.getByText('Update to-do list', { exact: true }).waitFor()
     await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
 

+ 14 - 5
apps/web/tests/sidebar-right.e2e.ts

@@ -266,9 +266,9 @@ describe('web e2e: shipped right Sidebar', () => {
       // real because the preview reads it through the workspace endpoint.
       //
       // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
-      // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`,
-      // which is the session header's cwd. Writing anywhere else makes the read
-      // fail with workspace-file/not-found, which is the endpoint being right.
+      // relative paths against the header-derived workspace root. Writing
+      // anywhere else makes the read fail with workspace-file/not-found, which
+      // is the endpoint being right.
       writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8')
       agent.session.append('tool/call', {
         turn: 1,
@@ -667,18 +667,27 @@ describe('web e2e: shipped right Sidebar', () => {
     it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
       const files = (scaffold.ctx as unknown as {
         get(name: string): {
-          read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }>
+          read(
+            scope: { sessionId: string; workspaceRoot: string },
+            path: string,
+            range: object,
+            signal: AbortSignal,
+          ): Promise<{ text: string; eof: boolean }>
         } | undefined
       }).get('workspaceFiles')
       if (files === undefined) throw new Error('host endpoint is not provided')
       const agent = scaffold.ctx.agents.list()[0]
       if (agent === undefined) throw new Error('no Agent to read for')
+      const scope = {
+        sessionId: agent.session.id,
+        workspaceRoot: agent.session.header.cwd ?? scaffold.workspaceCwd,
+      }
 
       // Raced against a timer so a hang reports a verdict instead of stalling
       // the suite: this case exists to tell host logic apart from the wire.
       // A page is the file's lines joined by `\n`, without the final terminator.
       const verdict = await Promise.race([
-        files.read(agent, SAMPLE_NAME, {}, new AbortController().signal)
+        files.read(scope, SAMPLE_NAME, {}, new AbortController().signal)
           .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof }))
           .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })),
         new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => {

+ 1 - 1
docs/AGENTS.md

@@ -35,7 +35,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb
 
 ## Writing rules
 
-- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems; the latter two may cite merged PRs and issues as evidence.
+- **Document current state, not change history.** Name live mechanisms, not PRs, commits, stack positions, or "previously/now/no longer". Keep history in commits, PRs, Agent Notes, or postmortems. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence.
 - **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)).
 - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
 - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).

+ 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: bba3732438bc323b034625185962c5bce2382f3c
-config-catalog.zh.md: 8c6168c46035e703a7c54e121dd20514e2ddf009
+config-catalog.md: 6c8ec4303ed641b653af7c33b0235ce3edfde4dc
+config-catalog.zh.md: 6751aeb9b682fcceaf5c6c39bded8c10d59e5e51

+ 2 - 2
docs/config-catalog.md

@@ -270,7 +270,7 @@ Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/ter
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 
-Requires: `fs` · `sandboxPolicy` · `typert`
+Requires: `fs` · `sandboxPolicy` · `sessions` · `typert`
 
 ```ts config-catalog
 /** Deployment caps on one page or one listing. */
@@ -292,7 +292,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
+Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 

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

@@ -272,7 +272,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 
-Requires: `fs` · `sandboxPolicy` · `typert`
+Requires: `fs` · `sandboxPolicy` · `sessions` · `typert`
 
 ```ts config-catalog
 /** Deployment caps on one page or one listing. */
@@ -294,7 +294,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
+来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 

+ 2 - 2
docs/cookbook/adding-a-session-format-version.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/cookbook/adding-a-session-format-version.md
-adding-a-session-format-version.md: c2d3b966c4ca2780e5ddef933f57715721a9dd43
-adding-a-session-format-version.zh.md: 6bb93f5ff92d38acc538c53b0ff5c477bf71915b
+adding-a-session-format-version.md: 54af2806bf7193ce9d1f5cd6c75f92b809c4ed95
+adding-a-session-format-version.zh.md: 4f7135b00aba37758343b8c17f2b4aff3146eeab

+ 14 - 14
docs/cookbook/adding-a-session-format-version.md

@@ -4,7 +4,7 @@ English | [中文](adding-a-session-format-version.zh.md)
 
 ## Summary
 
-Use this tutorial to introduce a structural Session log version without rewriting released data. The worked example adds V3 through one V2→V3 edge, then lets independently reviewed changes extend that unreleased edge. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md).
+Use this tutorial to introduce the next structural Session log version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md).
 
 ## Table of Contents
 
@@ -21,20 +21,20 @@ Use this tutorial to introduce a structural Session log version without rewritin
 
 Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Ordinary event additions do not require a bump; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions.
 
-Use a shared `release/*` integration base, such as `release/session-log-v3`. The base change adds the V3 writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child's branch. Each child adds its own structural transformation, validators, consumers, and tests to the same `session-format-v2-to-v3` package. Do not introduce V4 or V5 just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it.
+Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it.
 
-Released codecs and migration semantics remain frozen. Do not amend V0→V1 or V1→V2 to implement a new V3 feature. Before V3 ships, its single incoming edge can incorporate the coordinated changes; after release, a structural change needs the next adjacent edge.
+Released codecs and migration semantics remain frozen. Do not amend a released edge to implement a new structural feature. Only the N→N+1 edge may incorporate coordinated changes before N+1 ships; after release, further structural changes need the next adjacent edge.
 
-Use disposable, isolated Harness homes for unreleased integration testing. An interim V3 file already has the current version, so a later edit to V2→V3 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
+Use disposable, isolated Harness homes for unreleased N+1 integration testing. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
 
 <a id="add-an-identity-edge"></a>
 ## 2. Add an identity edge
 
-Follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold; the integrated [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) defines the actual transformations and preservation rules. Do not treat its structural conversion as an identity edge.
+Follow the package checklist to create a library for N→N+1, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is a fixed example of explicit transformations and preservation rules, not an edge to extend or treat as an identity conversion.
 
-Declare `dsh.sessionFormatMigration` in the package manifest with `from: 2`, `to: 3`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV2SessionFormatCodec` from the preceding edge and depend on that package; do not copy or redefine the released V2 codec. Export the V3 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references.
+Declare `dsh.sessionFormatMigration` with numeric `from: N` and `to: N+1`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse the source codec exported by the preceding edge package and depend on that package; do not copy or redefine a released codec. Export the target codec and validators from the new package. Add the edge as a direct dependency of the catalog and add the workspaces TypeScript paths and project references.
 
-Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to 3, then generate the catalog:
+Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to N+1 alongside the new edge declarations, then generate the catalog. The command below generates only the declared chain; it does not implement a new version:
 
 ```sh
 pnpm run gen-session-format-catalog
@@ -49,9 +49,9 @@ Use the [Stage interfaces](../../packages/session/session-format/src/types.ts),
 
 Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `finish(context)`. Emit synchronously through `context.emitEvent` or `context.emitRun`; a call can produce zero, one, or many outputs. Let a stage consume codec-owned compact runs directly, or iterate `run.expand()` without materializing an intermediate array. The caller owns scheduling, and the chain finishes upstream stages before downstream stages.
 
-Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test V0→V1→V2→V3 and V1→V2→V3 with seeded Sessions, not just direct V2 input. Never substitute zero for an unknown cut.
+Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test seeded multi-hop restoration from each supported historical generation through N+1, not just direct N input. Never substitute zero for an unknown cut.
 
-Define each edge's event admission and transformation rules explicitly; the [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) owns this edge's policy. The [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) owns the preceding edge's policy. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation.
+Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation.
 
 Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs.
 
@@ -67,9 +67,9 @@ Verify both read and write paths. Header-only listing must not read bodies or pu
 <a id="snapshot-successors"></a>
 ## 5. Create snapshot successors
 
-Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. For each role, keep the historical file and generate the current successor: `session.v3.jsonl` for the parent and `session.1.v3.jsonl`, `session.2.v3.jsonl`, and so on for children. Never rename `session.v2.jsonl` to V3 or change only its header.
+Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor using the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename or change only its header.
 
-For unchanged replay input, use keyless refresh on the owner, then replay without write-back. This concrete SDK example uses `text-turn`; select the actual affected owner for a feature:
+For unchanged replay input, use keyless refresh on the owner, then replay without write-back. These SDK commands use `text-turn` and the checkout's writer version. Implement and wire N+1 before using them to generate that version, and select the actual affected owner for a feature:
 
 ```sh
 pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn
@@ -83,7 +83,7 @@ Keep deliberate historical cases explicit through `snapshot.yml`'s `sessionForma
 <a id="validate"></a>
 ## 6. Validate the integrated result
 
-Run from the repository root. These focused commands check catalog declarations, Stage composition, the new edge, and generation selection:
+Run from the repository root. These commands check catalog declarations, Stage composition, the released V2→V3 edge, and generation selection. They are a baseline; add focused coverage for the new edge:
 
 ```sh
 pnpm run verify-session-format-catalog
@@ -91,9 +91,9 @@ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session
 pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts
 ```
 
-Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result.
+After implementing the new edge, add its actual test path to the focused Vitest run. Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result.
 
-Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks:
+Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Keep the [release record](../session-format-status.md#updating-the-record) unchanged until publication; after publication, update it with verified release evidence. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks:
 
 ```sh
 pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md

+ 14 - 14
docs/cookbook/adding-a-session-format-version.zh.md

@@ -4,7 +4,7 @@
 
 ## 概述
 
-本教程介绍如何添加结构性 Session 日志版本,同时不改写已发布数据。示例通过单条 V2→V3 迁移边添加 V3,再让独立评审的变更扩展这条尚未发布的迁移边。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。
+本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。
 
 ## 目录
 
@@ -21,20 +21,20 @@
 
 当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。普通事件新增不需要提升版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。
 
-使用共享的 `release/*` 集成基线,例如 `release/session-log-v3`。基线变更添加 V3 写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个 `session-format-v2-to-v3` 包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而引入 V4 或 V5。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。
+为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。
 
-已发布 codec 和迁移语义保持冻结。不要通过修改 V0→V1 或 V1→V2 来实现新的 V3 功能。在 V3 发布前,其唯一入边可以纳入这些协同变更;发布后,结构性变更需要下一条相邻迁移边。
+已发布 codec 和迁移语义保持冻结。不要通过修改已发布迁移边来实现新的结构性功能。只有 N→N+1 迁移边可在 N+1 发布前纳入协同变更;发布后,进一步的结构性变更需要下一条相邻迁移边。
 
-未发布版本的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 V3 文件已经标为当前版本,因此后续对 V2→V3 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
+未发布 N+1 的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
 
 <a id="add-an-identity-edge"></a>
 ## 2. 添加恒等迁移边
 
-按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架;集成后的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)定义实际转换与保留规则。不要将其结构转换视为恒等迁移边。
+按照包检查清单为 N→N+1 创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架[V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是明确转换与保留规则的固定示例,而不是可继续扩展或视为恒等转换的迁移边。
 
-在 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 2`、`to: 3`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边的 `releasedV2SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V2 codec。从新包导出 V3 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。
+在 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含数值 `from: N` 和 `to: N+1`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边所属包导出的源 codec,并依赖该包;不要复制或重新定义已发布 codec。从新包导出目标 codec 和校验器。将迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。
 
-将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 3,然后生成 catalog
+在添加新迁移边声明的同时,将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 N+1,然后生成 catalog。下面的命令只生成已声明的迁移链;它不会实现新版本
 
 ```sh
 pnpm run gen-session-format-catalog
@@ -49,9 +49,9 @@ pnpm run gen-session-format-catalog
 
 实现 `transformEvent(event, context)`、`transformRun(run, context)` 和 `finish(context)`。通过 `context.emitEvent` 或 `context.emitRun` 同步输出;一次调用可以产生零个、一个或多个输出。让 Stage 直接消费 codec 所有的紧凑 run,或者迭代 `run.expand()`,而不物化中间数组。调用方负责调度,迁移链先结束上游 Stage,再结束下游 Stage。
 
-继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试 V0→V1→V2→V3 和 V1→V2→V3,而非仅测试直接 V2 输入。绝不以零替代未知截点。
+继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并测试从每个受支持历史代际到 N+1 的有种子多跳恢复,而非仅测试直接 N 输入。绝不以零替代未知截点。
 
-显式定义每条迁移边的事件准入与变换规则;[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)负责本迁移边的策略。[Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)负责前代迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。
+显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。
 
 通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。
 
@@ -67,9 +67,9 @@ pnpm run gen-session-format-catalog
 <a id="snapshot-successors"></a>
 ## 5. 创建快照后继代际
 
-阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。为每个角色保留历史文件,并生成当前后继文件:父角色使用 `session.v3.jsonl`,子角色依次使用 `session.1.v3.jsonl`、`session.2.v3.jsonl` 等。绝不将 `session.v2.jsonl` 重命名为 V3,或仅修改其 header。
+阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并按目标版本的规范父子文件名生成后继文件。绝不将前代重命名为目标文件名,或仅修改其 header。
 
-如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。这个具体 SDK 示例使用 `text-turn`;功能变更应选择实际受影响的所有者:
+如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。以下 SDK 命令使用 `text-turn` 和工作区的写入器版本。先实现并接入 N+1,才能用它们生成该版本;功能变更应选择实际受影响的所有者:
 
 ```sh
 pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn
@@ -83,7 +83,7 @@ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn
 <a id="validate"></a>
 ## 6. 验证集成结果
 
-从仓库根目录运行。以下聚焦命令检查 catalog 声明、Stage 组合、新迁移边与代际选择
+从仓库根目录运行。以下命令检查 catalog 声明、Stage 组合、已发布的 V2→V3 迁移边与代际选择。它们是基线检查;需为新迁移边添加聚焦覆盖
 
 ```sh
 pnpm run verify-session-format-catalog
@@ -91,9 +91,9 @@ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session
 pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts
 ```
 
-根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。
+实现新迁移边后,将其实际测试路径加入聚焦的 Vitest 命令。根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。
 
-更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查:
+更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。发布前保持[发布记录](../session-format-status.zh.md#updating-the-record)不变;发布后,使用已核实的发布证据更新它。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查:
 
 ```sh
 pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.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/deepseek-llm-api-wire-extensions.md
-deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d
-deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72
+deepseek-llm-api-wire-extensions.md: a6689c97670260a7b673fc73d455726e357fd022
+deepseek-llm-api-wire-extensions.zh.md: 9f54d78609186055d3ac5e3b9b4f4272c7304e83

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.md

@@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
 
 ## `dsh_session_log`
 
-[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field.
+[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md).
 
 ```json
 {
@@ -119,7 +119,7 @@ The `session` member projects `Session.header`, not a complete runtime Session o
 
 | Member | Presence | Meaning |
 |---|---|---|
-| `version` | required | Logical Session format version; currently `2` |
+| `version` | required | Logical Session format version from `Session.header`; see [format status](session-format-status.md) |
 | `id` | required | Exact Session id |
 | `createdAt` | required | Non-negative safe-integer Unix epoch milliseconds |
 | `cwd` | optional | Absolute working directory recorded at Session creation |

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.zh.md

@@ -73,7 +73,7 @@
 
 ## `dsh_session_log`
 
-[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。
+[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。
 
 ```json
 {
@@ -119,7 +119,7 @@
 
 | 成员 | 出现条件 | 含义 |
 |---|---|---|
-| `version` | 必需 | 逻辑 Session 格式版本;当前为 `2` |
+| `version` | 必需 | 来自 `Session.header` 的逻辑 Session 格式版本;见[格式状态](session-format-status.zh.md) |
 | `id` | 必需 | 确切的会话 id |
 | `createdAt` | 必需 | 非负安全整数 Unix epoch 毫秒数 |
 | `cwd` | 可选 | 创建会话时记录的绝对工作目录 |

+ 6 - 0
docs/session-format-status.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write docs/session-format-status.md
+session-format-status.md: 9076574b9b12b4f75615ff06939795a8f0051def
+session-format-status.zh.md: 6c230479f8e41441f0c2e9848448a61d772a46b9

+ 47 - 0
docs/session-format-status.md

@@ -0,0 +1,47 @@
+# Session format version and release status
+
+English | [中文](session-format-status.zh.md)
+
+## Summary
+
+Use this reference to distinguish the checkout’s Session writer version from the latest published Session format. The code constant owns the writer version; the release record below owns the latest released format and its publication evidence. Other documentation links here instead of restating which version is current, next, or unreleased.
+
+## Table of Contents
+
+- [Sources of truth](#sources-of-truth)
+- [Release record](#release-record)
+- [Updating the record](#updating-the-record)
+- [Dev Note](#dev-note)
+
+<a id="sources-of-truth"></a>
+## Sources of truth
+
+- **Checkout writer:** `SESSION_FORMAT_VERSION` in [core Session types](../packages/core/session/src/types.ts) is the only hand-maintained current-writer number in code. The [catalog generator](../scripts/gen-session-format-catalog.ts) derives codec ordering and checks that adjacent migrations reach it. A package version, codec export name, fixture filename, or projection-cache version is not the writer authority.
+- **Latest released format:** `latestReleasedVersion` in the following record identifies the published Session format. `evidenceTag` names a published product release whose tagged writer has that value; it need not be the first release carrying the format. The bilingual copy is checked against the same record, not maintained as a separate decision.
+- **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. When comparing an older checkout against a newer branch’s verified record, a lower writer version identifies an older writer format; the local consistency gate rejects that ordering within one checkout. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record.
+
+An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning and authority decision](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion.
+
+<a id="release-record"></a>
+## Release record
+
+```yaml session-format-release
+latestReleasedVersion: 3
+evidenceTag: dsh-v0.1.5-alpha.1
+```
+
+Evidence: [published release](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) and [its tagged writer source](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts).
+
+<a id="updating-the-record"></a>
+## Updating the record
+
+When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and both evidence links in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk.
+
+The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-link consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update.
+
+Use “current format” and “next adjacent version” for general behavior. Keep explicit numbers for fixed migration inputs and outputs, wire schemas, historical evidence, and tests of those particular versions. The [format-version cookbook](cookbook/adding-a-session-format-version.md) uses N for the verified latest released format and N+1 for its successor.
+
+<a id="dev-note"></a>
+## Dev Note
+
+None.

+ 47 - 0
docs/session-format-status.zh.md

@@ -0,0 +1,47 @@
+# Session 格式版本与发布状态
+
+[English](session-format-status.md) | 中文
+
+## 概述
+
+本参考区分工作区的 Session 写入器版本与最新已发布的 Session 格式。代码常量拥有写入器版本;下方发布记录拥有最新已发布格式及其发布证据。其他文档链接到这里,而不重复声明哪个版本是当前、下一个或尚未发布的版本。
+
+## 目录
+
+- [单一真源](#sources-of-truth)
+- [发布记录](#release-record)
+- [更新记录](#updating-the-record)
+- [开发备注](#dev-note)
+
+<a id="sources-of-truth"></a>
+## 单一真源
+
+- **工作区写入器:**[核心 Session 类型](../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 是代码中唯一手工维护的当前写入器版本号。[目录生成器](../scripts/gen-session-format-catalog.ts)推导 codec 顺序,并检查相邻迁移是否到达该版本。包版本、codec 导出名称、fixture(测试前置数据)文件名或投影缓存版本都不是写入器版本的权威来源。
+- **最新已发布格式:**下方记录中的 `latestReleasedVersion` 标识已发布的 Session 格式。`evidenceTag` 指定一个已发布的产品版本,其标签对应的写入器具有该值;它不必是首次携带该格式的发布。双语副本按同一记录校验,不作为独立决策维护。
+- **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。用较新分支中已核实的记录对比旧工作区时,较低的写入器版本表示较旧的写入器格式;本地一致性门禁会拒绝同一工作区内的这种大小关系。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。
+
+产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本与真源决策](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。
+
+<a id="release-record"></a>
+## 发布记录
+
+```yaml session-format-release
+latestReleasedVersion: 3
+evidenceTag: dsh-v0.1.5-alpha.1
+```
+
+证据:[已发布产品版本](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1)及[对应标签的写入器源码](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts)。
+
+<a id="updating-the-record"></a>
+## 更新记录
+
+实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与两个证据链接。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。
+
+[文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据链接一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。
+
+一般行为使用“当前格式”和“下一条相邻版本”等表述。固定迁移的输入与输出、协议 schema、历史证据及针对特定版本的测试保留明确版本号。[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md)用 N 表示已核实的最新发布格式,用 N+1 表示其后继版本。
+
+<a id="dev-note"></a>
+## 开发备注
+
+无。

+ 2 - 2
docs/subsystems/persistence.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/persistence.md
-persistence.md: 8ba4e74768050646df4904d4ae1a978781685f35
-persistence.zh.md: 1821b07df7686a7aa77cfb5c837f903e199ccf94
+persistence.md: 7e18e9bbfec8c7aa81525da33ad3cffa4681ea73
+persistence.zh.md: 8250fb93b20dc33c2df705fd2c7092bc8dea155c

+ 2 - 2
docs/subsystems/persistence.md

@@ -186,7 +186,7 @@ interface SessionHeader {
 
 ## Format refusal — logs a build cannot faithfully read
 
-A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. Historical `open` calls share one per-session migration preparation before returning current logical values and leave every source path, byte, and inode unchanged. The JSONL provider returns a read handle from that in-memory result without publishing; a write open holds its single-writer claim and file lease while it reuses the preparation, exclusively publishes the final current generation, and only then returns the writable handle. A future highest generation refuses even when an older readable generation remains. Current v2 restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules.
+A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. Historical `open` calls share one per-session migration preparation before returning current logical values and leave every source path, byte, and inode unchanged. The JSONL provider returns a read handle from that in-memory result without publishing; a write open holds its single-writer claim and file lease while it reuses the preparation, exclusively publishes the final current generation, and only then returns the writable handle. A future highest generation refuses even when an older readable generation remains. Current-format restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1/v2 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules.
 
 ## `CreateSessionOptions` — seeding and metadata
 
@@ -202,7 +202,7 @@ interface CreateSessionOptions {
   /** Initial replay or fork history supplied at construction. */
   readonly seed?: readonly SessionEvent[]
   /**
-   * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the
+   * Exact fork-inherited prefix length when `meta.isSeeded` is true. The
    * constructor seed is exactly this inherited prefix; the constructor
    * appends the child-owned tagged marker at the cut.
    */

+ 2 - 2
docs/subsystems/persistence.zh.md

@@ -186,7 +186,7 @@ interface SessionHeader {
 
 ## 格式拒绝:本构建无法可靠读取的日志
 
-后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。历史 `open` 会共享每个 Session 唯一的一次 migration preparation,再返回当前逻辑值,并保持每个源路径、字节与 inode 不变。JSONL provider 直接从该内存结果返回读句柄而不发布;写 open 则在持有单写者 claim 与文件 lease 时复用 preparation、排他发布最终 current generation,随后才返回可写句柄。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前 v2 恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。
+后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。历史 `open` 会共享每个 Session 唯一的一次 migration preparation,再返回当前逻辑值,并保持每个源路径、字节与 inode 不变。JSONL provider 直接从该内存结果返回读句柄而不发布;写 open 则在持有单写者 claim 与文件 lease 时复用 preparation、排他发布最终 current generation,随后才返回可写句柄。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前格式恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1/v2 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。
 
 ## `CreateSessionOptions`:seed 与元数据
 
@@ -202,7 +202,7 @@ interface CreateSessionOptions {
   /** Initial replay or fork history supplied at construction. */
   readonly seed?: readonly SessionEvent[]
   /**
-   * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the
+   * Exact fork-inherited prefix length when `meta.isSeeded` is true. The
    * constructor seed is exactly this inherited prefix; the constructor
    * appends the child-owned tagged marker at the cut.
    */

+ 2 - 2
docs/subsystems/session.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.md
-session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7
-session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b
+session.md: 1443f6c3e5795d5a02662f25c3b39525084bb085
+session.zh.md: 8e4ca4b4b5c5b113ff6989a92030e318a888f449

+ 2 - 2
docs/subsystems/session.md

@@ -656,7 +656,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
 
 A fresh fork constructor requires its seed to equal the inherited prefix and appends `session/end-seed { inherited: true }` at the exact durable cut. A restore retains that tagged marker and appends an ordinary `session/end-seed {}` only when its complete stored seed does not already end in a marker. Both forms are log-only and produce no message; `Session`'s constructor is the only legitimate writer.
 
-For fork lineage, locate the LAST marker whose payload carries `inherited: true`; v2 decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker.
+For fork lineage, locate the LAST marker whose payload carries `inherited: true`; current-format decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker.
 
 It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`.
 
@@ -672,7 +672,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h
 
 ## Durability contract
 
-What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL v2 writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
+What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
 
 The backends that consume this contract are on [persistence.md](persistence.md).
 

+ 2 - 2
docs/subsystems/session.zh.md

@@ -660,7 +660,7 @@ interface TurnEndReasonMap {
 
 新 fork constructor 要求 seed 等于 inherited prefix,并在精确持久 cut 追加 `session/end-seed { inherited: true }`。restore 会保留该 tagged marker,并且只在完整 stored seed 尚未以 marker 结尾时追加普通 `session/end-seed {}`。两种形式都只进入 log 且不产生 message;`Session` constructor 是唯一合法 writer。
 
-对于 fork lineage,定位 payload 携带 `inherited: true` 的最后一个 marker;v2 decoding 只在 `SessionHeader.isSeeded` 为 true 时要求该 marker,并从其 seq 推导 `inheritedEventCount`。对于 lifecycle ownership,定位任一形式的最后一个 `session/end-seed`。重新打开已经以任一 marker 结尾的 seed 时,不会再追加普通 marker。
+对于 fork lineage,定位 payload 携带 `inherited: true` 的最后一个 marker;当前格式 decoding 只在 `SessionHeader.isSeeded` 为 true 时要求该 marker,并从其 seq 推导 `inheritedEventCount`。对于 lifecycle ownership,定位任一形式的最后一个 `session/end-seed`。重新打开已经以任一 marker 结尾的 seed 时,不会再追加普通 marker。
 
 它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。
 
@@ -676,7 +676,7 @@ interface TurnEndReasonMap {
 
 ## 持久性约定
 
-持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL v2 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
+持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
 
 消费此约定的后端见 [persistence.md](persistence.zh.md)。
 

+ 2 - 2
docs/subsystems/workspace.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/workspace.md
-workspace.md: f37af82d7f8cec018d24a22534bc0b7342425d34
-workspace.zh.md: 02dd976abde63992b65f09ea4852cedf0d0b4b36
+workspace.md: 94031d6803f5de3a4ddb3e3654d40bebe74a92cc
+workspace.zh.md: 2b131f9bfd51ecf63bd11b6e91ed181bc126ab94

+ 18 - 20
docs/subsystems/workspace.md

@@ -334,76 +334,74 @@ Host Remote file reads and workspace directory observations over the composed fi
 ```ts cordis-catalog
 /**
  * Read one page of lines from a UTF-8 file readable by the filesystem backend.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
  */
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise<WorkspaceFileText>
 
 /**
  * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete regular file as bytes, subject to the configured full-file cap.
- * @param agent - target Agent whose workspace resolves relative paths.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute or workspace-relative file path.
  * @param signal - caller cancellation.
  * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
  */
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
- * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - base file, absolute or workspace-relative.
  * @param relativePath - relative filesystem path, not a URL or absolute path.
  * @param signal - caller cancellation.
  * @returns the complete related file using the ordinary file-size and access checks.
  */
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Report one regular file's identity, version, and size without its content.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 
 /**
- * List the direct children of one directory inside the Agent's workspace.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * List the direct children of one directory inside the Session's workspace.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - workspace path, absolute or relative to the workspace root.
  * @param signal - caller cancellation.
  * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
  */
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 
 /**
- * Stream every `fs/observed` observation of a file inside the Agent's
- * workspace. Only Agent filesystem operations report here; the OS is not
- * watched.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * Stream every `fs/observed` observation of a file inside the Session's
+ * workspace. Only instrumented filesystem operations report here; the OS is
+ * not watched.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param signal - generation cancellation.
  * @returns `ready` once the Host observation queue is active and the workspace
  *   root is resolved, then queued and live observations in emission order.
  */
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
-Types: [Agent](core.md)
-
 Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
 
 <a id="ctxworkspaceregistry--workspaceregistry"></a>

+ 18 - 20
docs/subsystems/workspace.zh.md

@@ -334,76 +334,74 @@ Host Remote file reads and workspace directory observations over the composed fi
 ```ts cordis-catalog
 /**
  * Read one page of lines from a UTF-8 file readable by the filesystem backend.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
  */
-@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
+@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise<WorkspaceFileText>
 
 /**
  * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
-@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete regular file as bytes, subject to the configured full-file cap.
- * @param agent - target Agent whose workspace resolves relative paths.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute or workspace-relative file path.
  * @param signal - caller cancellation.
  * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
  */
-@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
- * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - base file, absolute or workspace-relative.
  * @param relativePath - relative filesystem path, not a URL or absolute path.
  * @param signal - caller cancellation.
  * @returns the complete related file using the ordinary file-size and access checks.
  */
-@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
 
 /**
  * Report one regular file's identity, version, and size without its content.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */
-@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
+@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 
 /**
- * List the direct children of one directory inside the Agent's workspace.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * List the direct children of one directory inside the Session's workspace.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param path - workspace path, absolute or relative to the workspace root.
  * @param signal - caller cancellation.
  * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
  */
-@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
+@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 
 /**
- * Stream every `fs/observed` observation of a file inside the Agent's
- * workspace. Only Agent filesystem operations report here; the OS is not
- * watched.
- * @param agent - target Agent resolved from the Session identity on the wire.
+ * Stream every `fs/observed` observation of a file inside the Session's
+ * workspace. Only instrumented filesystem operations report here; the OS is
+ * not watched.
+ * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
  * @param signal - generation cancellation.
  * @returns `ready` once the Host observation queue is active and the workspace
  *   root is resolved, then queued and live observations in emission order.
  */
-@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
+@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
 ```
 
-Types: [Agent](core.zh.md)
-
 Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
 
 <a id="ctxworkspaceregistry--workspaceregistry"></a>

+ 2 - 2
docs/testing.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/testing.md
-testing.md: b9800bd7aa25e2556d2fa97e9397c140fffdb442
-testing.zh.md: 59f1a7ca05f0e50f6a3999498b41670228618514
+testing.md: bbf7db5d788667dc85c34dfdac4784e2b0dccca8
+testing.zh.md: 7d386494bb8fd712b93aeeeb4f8c7b196349a1db

+ 1 - 1
docs/testing.md

@@ -14,7 +14,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
 - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff.
 - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS.
 
-Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current V3 uses `.v3`, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors.
+Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors.
 
 ## How specs execute
 

+ 1 - 1
docs/testing.zh.md

@@ -14,7 +14,7 @@
 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。
 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。
 
-Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 V3 使用 `.v3`、每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。
+Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。
 
 ## spec 如何被执行
 

+ 2 - 2
packages/api/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/api/README.md
-README.md: a60e47948ffcb54e07b33ff63a1ea92e3c05998e
-README.zh.md: 9e374c90c808f4314ba6565038bdba6bcca80cf9
+README.md: f1aefa7d4564539f94202e869bacb85857f939d5
+README.zh.md: ed5fee31a06143ea3d0602959ef9cab01f8ff729

+ 1 - 1
packages/api/README.md

@@ -32,7 +32,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust
 | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
 | [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` |
-| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
+| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.
 

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

@@ -32,7 +32,7 @@ kind: "package-group"
 | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
 | [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` |
-| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
+| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。
 

+ 2 - 2
packages/api/workspace-files/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/api/workspace-files/README.md
-README.md: e49f5bef7291233eb688bc9aeab44e56efb3d7fa
-README.zh.md: 1d3808969ac377408801a7516978bbebd6860b62
+README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a
+README.zh.md: c33d0632fde318c330da4102211b69ad2be048f7

+ 7 - 7
packages/api/workspace-files/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and Agent-write observation inside the Session workspace root."
+description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and instrumented filesystem observation inside the Session workspace root."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and Agent-write change observation remain workspace-scoped. The service exposes no mutation operation.
+Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and instrumented filesystem observations remain workspace-scoped. The service exposes no mutation operation.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(agent, path, range, signal)`, `stat(agent, path, signal)`, `readBytes(agent, path, range, signal)`, `list(agent, path, signal)`, or `changes(agent, signal)` and never names a root itself.
+Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`.
 
 | Method | Returns | Purpose |
 |---|---|---|
@@ -35,11 +35,11 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway;
 | `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated |
 | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory |
-| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root |
+| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then filesystem observations inside the workspace root |
 
 ### Addressing and paths
 
-`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only Agent observations inside the workspace root.
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only instrumented filesystem observations inside the workspace root.
 
 ### Pages
 
@@ -92,7 +92,7 @@ One supervised `changes` stream serves every followed file in a Session. Followe
 
 ### Design concept
 
-Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
+Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
 
 ### Source map
 
@@ -136,7 +136,7 @@ None; this package neither assembles nor sends a provider request.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
+- **Instrumented operations only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
 - **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend.
 - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`.
 - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes.

+ 7 - 7
packages/api/workspace-files/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察 Agent 写入。"
+description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察已埋点的文件系统操作。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与 Agent 写入变更观察仍限定于工作区。本服务不提供修改操作。
+使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与已埋点的文件系统观察仍限定于工作区。本服务不提供修改操作。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-把本包与 `dsh-fs`、`dsh-sandbox-policy` 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(agent, path, range, signal)`、`stat(agent, path, signal)`、`list(agent, path, signal)` 或 `changes(agent, signal)`,从不自己指定根
+把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`
 
 | 方法 | 返回 | 用途 |
 |---|---|---|
@@ -35,11 +35,11 @@ kind: "package-reference"
 | `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 |
 | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 |
-| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 |
+| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的文件系统观察 |
 
 ### 寻址与路径
 
-`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内的 Agent 观察。
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内已埋点的文件系统观察。
 
 ### 分页
 
@@ -92,7 +92,7 @@ kind: "package-reference"
 
 ### 设计概念
 
-经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
+经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
 
 ### 源码地图
 
@@ -136,7 +136,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
+- **仅覆盖已埋点操作**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
 - **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。
 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。
 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。

+ 1 - 1
packages/api/workspace-files/package.json

@@ -62,13 +62,13 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
   },
   "files": [

+ 2 - 2
packages/api/workspace-files/src/changes.ts

@@ -1,8 +1,8 @@
 /**
  * Producer of the `changes` stream: every `fs/observed` emission whose target
  * lies inside a generation's workspace root becomes one frame of that
- * generation. Observations are emitted by tools after their own filesystem
- * operation, so the feed covers Agent writes only; the OS is not watched.
+ * generation. Instrumented filesystem operations emit these observations; the
+ * operating system is not watched.
  * Each generation acknowledges its observation queue and resolved workspace
  * root with `ready` before emitting any queued or live changes.
  */

+ 96 - 50
packages/api/workspace-files/src/index.ts

@@ -1,12 +1,14 @@
 /**
  * Workspace file service: read-only file previews, workspace directory
- * listings, and the agent-write change feed, exposed as `workspaceFiles`.
+ * listings, and the filesystem-observation change feed, exposed as
+ * `workspaceFiles`.
  *
  * File reads follow the composed filesystem's read access, including paths
- * outside the workspace. The Session's policy supplies the base for relative
- * paths, not a read-containment restriction. Directory listings and change
- * observations remain workspace-scoped. File-kind checks and configured read
- * caps apply to every preview; this service exposes no mutations.
+ * outside the workspace. The selected Session header supplies the base for
+ * relative paths, with the sandbox policy root as its no-cwd fallback, not a
+ * read-containment restriction. Directory listings and change observations
+ * remain workspace-scoped. File-kind checks and configured read caps apply to
+ * every preview; this service exposes no mutations.
  *
  * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
  * goes, so the file is read only up to the first character past the page and
@@ -20,11 +22,13 @@
 import { posix, win32 } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-fs'
 import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
-import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-persistence'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
 import { WorkspaceChangeFeed } from './changes.ts'
 import type {
   WorkspaceByteRange,
@@ -46,6 +50,21 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
+/** Header-derived file resolution context for one Session identity. */
+export interface WorkspaceFileScope {
+  /** Session identity received on the wire. */
+  readonly sessionId: SessionId
+  /** Session workspace root, or the deployment fallback when its header has no cwd. */
+  readonly workspaceRoot: string
+}
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertLookupMap {
+    /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */
+    workspaceFileScope: TypertLookup<WorkspaceFileScope, SessionId>
+  }
+}
+
 /** Deployment caps on one page or one listing. */
 export interface Config {
   /**
@@ -161,7 +180,7 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
 
 /** Host Remote file reads and workspace directory observations over the composed filesystem. */
 export class WorkspaceFiles extends TypertRemoteService {
-  static inject = ['fs', 'sandboxPolicy', 'typert']
+  static inject = ['fs', 'sandboxPolicy', 'sessions', 'typert']
 
   static Config: z<Config> = z.object({
     maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
@@ -179,20 +198,45 @@ export class WorkspaceFiles extends TypertRemoteService {
   constructor(ctx: Context, private readonly config: Config) {
     super(ctx, 'workspaceFiles')
     this.feed = new WorkspaceChangeFeed(ctx)
+    ctx.inject(['sessions', 'typert'], (scope) => {
+      scope.typert.lookups.register('workspaceFileScope', {
+        parameter: 'workspaceFileScope',
+        wire: 'workspaceFileScopeId',
+        hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
+        wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+        resolve: async (sessionId) => {
+          const live = scope.sessions.get(sessionId)?.header
+          const stored = live === undefined
+            ? await scope.get('sessionPersistence')?.stat(sessionId)
+            : undefined
+          const header = live ?? stored?.header
+          if (header === undefined) return undefined
+          return {
+            sessionId,
+            workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot,
+          }
+        },
+      })
+    })
   }
 
   /**
    * Read one page of lines from a UTF-8 file readable by the filesystem backend.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the line window; omitted fields take the page defaults.
    * @param signal - caller cancellation.
    * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
    */
   @Remote
-  async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText> {
+  async read(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    range: WorkspaceFileRange,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileText> {
     const { offset, limit } = this.resolvePage(range)
-    const { target, info } = await this.locateFile(agent, path, signal)
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const page = await this.cutPage(target, offset, limit, signal, path)
     if (page.text.includes(NUL)) {
       throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path })
@@ -203,16 +247,21 @@ export class WorkspaceFiles extends TypertRemoteService {
   /**
    * Read one byte window of a regular file readable by the filesystem backend: raw
    * bytes, no text decoding and no binary rejection.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the byte window; omitted fields take the window defaults.
    * @param signal - caller cancellation.
    * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
    */
   @Remote
-  async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+  async readBytes(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    range: WorkspaceByteRange,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileBytes> {
     const { offset, length } = this.resolveWindow(range, path)
-    const { target, info } = await this.locateFile(agent, path, signal)
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal)
     const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size
     return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof }
@@ -220,14 +269,14 @@ export class WorkspaceFiles extends TypertRemoteService {
 
   /**
    * Read a complete regular file as bytes, subject to the configured full-file cap.
-   * @param agent - target Agent whose workspace resolves relative paths.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute or workspace-relative file path.
    * @param signal - caller cancellation.
    * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
    */
   @Remote
-  async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
-    const { target, info } = await this.locateFile(agent, path, signal)
+  async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const limit = this.config.maxFileBytes
     if (info.size !== undefined && info.size > limit) {
       throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
@@ -241,47 +290,52 @@ export class WorkspaceFiles extends TypertRemoteService {
 
   /**
    * Read a complete file relative to another file's directory, including outside the workspace.
-   * @param agent - Agent whose workspace resolves the base file's relative path.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - base file, absolute or workspace-relative.
    * @param relativePath - relative filesystem path, not a URL or absolute path.
    * @param signal - caller cancellation.
    * @returns the complete related file using the ordinary file-size and access checks.
    */
   @Remote
-  async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+  async readRelated(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    relativePath: string,
+    signal: AbortSignal,
+  ): Promise<WorkspaceFileBytes> {
     const relative = relativePath.replace(/\\/g, '/')
     if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) {
       throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {})
     }
-    const { target } = await this.locateFile(agent, path, signal)
+    const { target } = await this.locateFile(workspaceFileScope, path, signal)
     const absolute = this.ctx.fs.processPath(target)
     const paths = absolute.startsWith('/') ? posix : win32
-    return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal)
+    return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal)
   }
 
   /**
    * Report one regular file's identity, version, and size without its content.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param signal - caller cancellation.
    * @returns the file's absolute path, current version, and byte size.
    */
   @Remote
-  async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
-    const { target, info } = await this.locateFile(agent, path, signal)
+  async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat> {
+    const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     return this.statOf(target, info)
   }
 
   /**
-   * List the direct children of one directory inside the Agent's workspace.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * List the direct children of one directory inside the Session's workspace.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param path - workspace path, absolute or relative to the workspace root.
    * @param signal - caller cancellation.
    * @returns the directory's children in the backend's stable name order, bounded by the entry cap.
    */
   @Remote
-  async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
-    const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal)
+  async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
+    const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
     if (entry.type !== 'directory') {
       throw new RemoteError(
         'workspace-file/not-directory',
@@ -299,17 +353,17 @@ export class WorkspaceFiles extends TypertRemoteService {
   }
 
   /**
-   * Stream every `fs/observed` observation of a file inside the Agent's
-   * workspace. Only Agent filesystem operations report here; the OS is not
-   * watched.
-   * @param agent - target Agent resolved from the Session identity on the wire.
+   * Stream every `fs/observed` observation of a file inside the Session's
+   * workspace. Only instrumented filesystem operations report here; the OS is
+   * not watched.
+   * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
    * @param signal - generation cancellation.
    * @returns `ready` once the Host observation queue is active and the workspace
    *   root is resolved, then queued and live observations in emission order.
    */
   @Remote({ mode: 'stream' })
-  changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
-    return this.feed.follow(this.workspaceRootOf(agent), signal)
+  changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
+    return this.feed.follow(workspaceFileScope.workspaceRoot, signal)
   }
 
   /** Apply the page defaults and caps here, so the request never carries them implicitly. */
@@ -338,29 +392,17 @@ export class WorkspaceFiles extends TypertRemoteService {
     }
     return { offset, length }
   }
-
-
-  /**
-   * The workspace root comes from the policy, not from the backend's own cwd
-   * default: the `minimal` preset shadows the host provider with a bare
-   * `fs-local` whose cwd differs, and resolving explicitly makes the answer
-   * the same whichever instance answers.
-   */
-  private workspaceRootOf(agent: Agent): string {
-    return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot
-  }
-
   /**
    * Inspect the requested path itself before resolution follows its final
    * component. Directory containment is checked separately by `list`.
    */
   private async inspect(
-    agent: Agent,
+    workspaceFileScope: WorkspaceFileScope,
     path: string,
     signal: AbortSignal,
   ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> {
     if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {})
-    const workspaceRoot = this.workspaceRootOf(agent)
+    const { workspaceRoot } = workspaceFileScope
     const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
     // Gate on the path itself before anything follows it.
     const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal)
@@ -384,8 +426,12 @@ export class WorkspaceFiles extends TypertRemoteService {
    * and size. The stat re-checks what `lstat` saw: the file may have gone or
    * changed kind in between.
    */
-  private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> {
-    const { workspaceRoot, entry } = await this.inspect(agent, path, signal)
+  private async locateFile(
+    workspaceFileScope: WorkspaceFileScope,
+    path: string,
+    signal: AbortSignal,
+  ): Promise<{ target: FsTarget; info: FsInfo }> {
+    const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
     if (entry.type !== 'file') {
       throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
     }

+ 3 - 3
packages/api/workspace-files/src/types.ts

@@ -115,9 +115,9 @@ export interface WorkspaceDirectoryListing {
 }
 
 /**
- * One observation of a workspace file made by an Agent's own filesystem
- * operation. Frames report observations, not deltas: a consumer already holding
- * `version` learns nothing new from the frame and can ignore it.
+ * One observation of a workspace file made by an instrumented filesystem
+ * operation. Frames report observations, not deltas: a consumer already
+ * holding `version` learns nothing new from the frame and can ignore it.
  */
 export type WorkspaceFileChange =
   | {

+ 3 - 3
packages/api/workspace-files/tests/changes.spec.ts

@@ -6,7 +6,7 @@ import type { FsObservation } from '@deepseek-ai/dsh-fs'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
 import { WorkspaceFiles } from '../src/index.ts'
 import type { WorkspaceFileWatchFrame } from '../src/types.ts'
-import { agent, openWorkspace, type Harness } from './harness.ts'
+import { openWorkspace, type Harness } from './harness.ts'
 
 let harness: Harness
 const closeStreams: Array<() => Promise<unknown>> = []
@@ -38,7 +38,7 @@ function open(
   service: WorkspaceFiles,
   controller = new AbortController(),
 ): { next(): Promise<IteratorResult<WorkspaceFileWatchFrame>>; controller: AbortController } {
-  const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+  const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]()
   closeStreams.push(async () => {
     controller.abort()
     await iterator.return?.()
@@ -245,7 +245,7 @@ describe('workspaceFiles.changes — ending', () => {
   it('stops delivering to a generation the consumer returned from', async () => {
     const service = harness.endpoint()
     const controller = new AbortController()
-    const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+    const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]()
     closeStreams.push(async () => {
       controller.abort()
       await iterator.return?.()

+ 12 - 7
packages/api/workspace-files/tests/harness.ts

@@ -12,13 +12,15 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
-import { WorkspaceFiles, type Config } from '../src/index.ts'
+import { WorkspaceFiles, type Config, type WorkspaceFileScope } from '../src/index.ts'
 
-/** The Agent shape the service reads: only its session reaches the policy. */
-export const agent = { id: 'a-test', session: { id: 's-test' } } as unknown as Agent
+/** Build the header-derived scope that direct service calls receive after Typert lookup. */
+function fileScope(workspaceRoot: string): WorkspaceFileScope {
+  return { sessionId: SessionId('s-test'), workspaceRoot }
+}
 
 export const signal = (): AbortSignal => new AbortController().signal
 
@@ -27,6 +29,7 @@ export interface Harness {
   readonly workspace: string
   readonly outside: string
   readonly ctx: Context
+  readonly scope: WorkspaceFileScope
   /**
    * The service under test, at the given caps. One per test: the service key is
    * global to the Context, so a second call with caps is a defect in the test.
@@ -49,14 +52,16 @@ export async function openWorkspace(prefix: string): Promise<Harness> {
   await mkdir(outside, { recursive: true })
   const ctx = new Context()
   const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace })
-  // The policy is the service's only source for the workspace root, so the
-  // fake supplies exactly that and nothing else.
-  ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }) } as never)
+  ctx.provide('sandboxPolicy', {
+    workspaceRoot: workspace,
+    resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }),
+  } as never)
   let service: WorkspaceFiles | undefined
   return {
     workspace,
     outside,
     ctx,
+    scope: fileScope(workspace),
     endpoint: (caps) => {
       if (service !== undefined) {
         if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint')

+ 13 - 13
packages/api/workspace-files/tests/list.spec.ts

@@ -2,7 +2,7 @@
 import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -25,7 +25,7 @@ describe('workspaceFiles.list — the happy path', () => {
     await mkdir(join(workspace, 'src'))
     await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
     await writeFile(join(workspace, '.hidden'), '', 'utf8')
-    const listing = await endpoint().list(agent, '.', signal())
+    const listing = await endpoint().list(harness.scope, '.', signal())
     expect(listing.path).toBe('')
     expect(listing.truncated).toBe(false)
     expect(listing.entries).toEqual([
@@ -36,7 +36,7 @@ describe('workspaceFiles.list — the happy path', () => {
   })
 
   it('accepts the absolute workspace root and reports the same empty path', async () => {
-    const listing = await endpoint().list(agent, workspace, signal())
+    const listing = await endpoint().list(harness.scope, workspace, signal())
     expect(listing.path).toBe('')
     expect(listing.entries).toEqual([])
   })
@@ -44,7 +44,7 @@ describe('workspaceFiles.list — the happy path', () => {
   it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => {
     await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true })
     await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8')
-    const listing = await endpoint().list(agent, 'src/my dir/子目录', signal())
+    const listing = await endpoint().list(harness.scope, 'src/my dir/子目录', signal())
     expect(listing.path).toBe('src/my dir/子目录')
     expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts'])
   })
@@ -55,7 +55,7 @@ describe('workspaceFiles.list — the happy path', () => {
     await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file'))
     await symlink(join(workspace, 'dir'), join(workspace, 'to-dir'))
     await symlink(join(workspace, 'missing'), join(workspace, 'dangling'))
-    const listing = await endpoint().list(agent, '.', signal())
+    const listing = await endpoint().list(harness.scope, '.', signal())
     expect(listing.entries).toEqual([
       { name: 'dangling', type: 'other' },
       { name: 'dir', type: 'directory' },
@@ -69,14 +69,14 @@ describe('workspaceFiles.list — the happy path', () => {
 describe('workspaceFiles.list — the entry cap', () => {
   it('cuts at the cap in name order and says so', async () => {
     for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8')
-    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
     expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b'])
     expect(listing.truncated).toBe(true)
   })
 
   it('does not report a cut at exactly the cap', async () => {
     for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8')
-    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
     expect(listing.entries).toHaveLength(2)
     expect(listing.truncated).toBe(false)
   })
@@ -84,36 +84,36 @@ describe('workspaceFiles.list — the entry cap', () => {
 
 describe('workspaceFiles.list — gates', () => {
   it('rejects an absolute directory outside the workspace', async () => {
-    const failure = await failureOf(endpoint().list(agent, outside, signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, outside, signal()))
     expect(failure.code).toBe('workspace-file/outside-workspace')
   })
 
   it('rejects a traversal that climbs out of the workspace', async () => {
-    const failure = await failureOf(endpoint().list(agent, '..', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, '..', signal()))
     expect(failure.code).toBe('workspace-file/outside-workspace')
   })
 
   it('rejects a symlinked directory before following it, wherever it points', async () => {
     await symlink(outside, join(workspace, 'escape'))
-    const failure = await failureOf(endpoint().list(agent, 'escape', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal()))
     expect(failure.code).toBe('workspace-file/not-directory')
     expect(failure.details).toMatchObject({ kind: 'symlink' })
   })
 
   it('rejects a file, which has no children to list', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
-    const failure = await failureOf(endpoint().list(agent, 'notes.txt', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'notes.txt', signal()))
     expect(failure.code).toBe('workspace-file/not-directory')
     expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' })
   })
 
   it('reports a missing path as not found', async () => {
-    const failure = await failureOf(endpoint().list(agent, 'nope', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, 'nope', signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
   it('refuses an empty path as a bad request', async () => {
-    const failure = await failureOf(endpoint().list(agent, '', signal()))
+    const failure = await failureOf(endpoint().list(harness.scope, '', signal()))
     expect(failure.code).toBe('gateway/bad-request')
   })
 })

+ 19 - 19
packages/api/workspace-files/tests/read-all.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 
@@ -14,21 +14,21 @@ describe('workspaceFiles.readAll', () => {
   it('reads the complete bytes independently of the window cap', async () => {
     const bytes = Buffer.from([0, 255, 1, 2])
     await writeFile(join(harness.workspace, 'file.bin'), bytes)
-    const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal())
+    const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal())
     expect(Buffer.from(result.data, 'base64')).toEqual(bytes)
     expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 })
   })
 
   it('returns an empty complete file', async () => {
     await writeFile(join(harness.workspace, 'empty'), '')
-    expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
+    expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
   })
 
   it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => {
     const path = join(harness[location], 'large')
     await writeFile(path, 'abcde')
     const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
-    expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, path, signal())))
+    expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal())))
       .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } })
     expect(read).not.toHaveBeenCalled()
   })
@@ -36,7 +36,7 @@ describe('workspaceFiles.readAll', () => {
   it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => {
     await writeFile(join(harness.workspace, 'growing'), 'abcde')
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } })
-    expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code)
+    expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code)
       .toBe('workspace-file/too-large')
   })
 
@@ -44,9 +44,9 @@ describe('workspaceFiles.readAll', () => {
     await mkdir(join(harness.workspace, 'directory'))
     await writeFile(join(harness.outside, 'outside'), 'outside')
     const files = harness.endpoint()
-    expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file')
-    const outside = await files.readAll(agent, join(harness.outside, 'outside'), signal())
+    expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file')
+    const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal())
     expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside')
   })
 })
@@ -58,25 +58,25 @@ describe('workspaceFiles.readRelated', () => {
     await writeFile(join(harness.workspace, 'nested/near.txt'), 'near')
     await writeFile(join(harness.workspace, 'root.txt'), 'root')
     const files = harness.endpoint()
-    const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal())
-    const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal())
+    const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal())
+    const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal())
     expect(Buffer.from(near.data, 'base64').toString()).toBe('near')
     expect(Buffer.from(root.data, 'base64').toString()).toBe('root')
-    const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal())
+    const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal())
     expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near')
   })
 
   it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => {
-    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request')
   })
 
   it('resolves related files on either side of the workspace root', async () => {
     await writeFile(join(harness.workspace, 'base'), 'base')
     await writeFile(join(harness.outside, 'outside'), 'outside')
     const files = harness.endpoint()
-    expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found')
-    const fromOutside = await files.readRelated(agent, join(harness.outside, 'outside'), '../workspace/base', signal())
-    const toOutside = await files.readRelated(agent, 'base', '../outside/outside', signal())
+    expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found')
+    const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal())
+    const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal())
     expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base')
     expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside')
   })
@@ -86,7 +86,7 @@ describe('workspaceFiles.readRelated', () => {
     const base = join(harness.outside, 'space # assets', 'page.html')
     await writeFile(base, '<script src="./app.js"></script>')
     await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET')
-    const result = await harness.endpoint().readRelated(agent, base, './app.js', signal())
+    const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal())
     expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET')
   })
 
@@ -100,14 +100,14 @@ describe('workspaceFiles.readRelated', () => {
     vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base)
     const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true })
     const caller = signal()
-    await files.readRelated(agent, 'base', './app.js', caller)
-    expect(read).toHaveBeenCalledWith(agent, expected, caller)
+    await files.readRelated(harness.scope, 'base', './app.js', caller)
+    expect(read).toHaveBeenCalledWith(harness.scope, expected, caller)
   })
 
   it('rejects a related symlink rather than following it', async () => {
     await writeFile(join(harness.workspace, 'base'), 'base')
     await writeFile(join(harness.workspace, 'target'), 'target')
     await symlink('target', join(harness.workspace, 'link'))
-    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file')
+    expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file')
   })
 })

+ 21 - 21
packages/api/workspace-files/tests/read-bytes.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -27,7 +27,7 @@ const decode = (data: string): Buffer => Buffer.from(data, 'base64')
 describe('workspaceFiles.readBytes — the window', () => {
   it('returns the whole file as one window by default, with its absolute path, version, and size', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(decode(result.data).equals(RAMP)).toBe(true)
     expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 })
     expect(result.absolutePath.endsWith('ramp.bin')).toBe(true)
@@ -36,14 +36,14 @@ describe('workspaceFiles.readBytes — the window', () => {
 
   it('cuts the requested window and reports that more follows', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal())
     expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23])
     expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 })
   })
 
   it('reads a window of a file far above the byte cap', async () => {
     await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7))
-    const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
+    const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
     expect(decode(result.data)).toHaveLength(1000)
     expect(result).toMatchObject({ eof: true, bytes: 200_000 })
   })
@@ -51,46 +51,46 @@ describe('workspaceFiles.readBytes — the window', () => {
   it('infers eof from a short window when the backend reports no size', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
-    const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal())
+    const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal())
     expect(full.eof).toBe(false)
-    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal())
+    const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal())
     expect(short).toMatchObject({ eof: true })
     expect(short.bytes).toBeUndefined()
   })
 
   it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal())
+    const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal())
     expect(exact.eof).toBe(true)
     expect(decode(exact.data)).toHaveLength(8)
-    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal())
+    const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal())
     expect(short.eof).toBe(true)
     expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255])
   })
 
   it('returns an empty eof window for an offset at or past the end', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal())
     expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 })
   })
 
   it('returns an empty eof window for an empty file', async () => {
     await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0))
-    const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal())
     expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
   })
 
   it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => {
     const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0])
     await writeFile(join(workspace, 'blob.bin'), raw)
-    const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal())
+    const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal())
     expect(decode(result.data).equals(raw)).toBe(true)
   })
 
   it('names the version a stat of the same file reports', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const stat = await endpoint().stat(agent, 'ramp.bin', signal())
-    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal())
+    const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(result.version).toBe(stat.version)
   })
 })
@@ -98,21 +98,21 @@ describe('workspaceFiles.readBytes — the window', () => {
 describe('workspaceFiles.readBytes — defaults and cap', () => {
   it('defaults the length to the configured byte cap', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
-    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal())
+    const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal())
     expect(decode(result.data)).toHaveLength(64)
     expect(result.eof).toBe(true)
   })
 
   it('refuses a window longer than the cap as too-large rather than shortening it', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP)
-    const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
     expect(failure.details).toMatchObject({ limit: 64 })
   })
 
   it('accepts a window exactly at the cap', async () => {
     await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
-    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal())
+    const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal())
     expect(decode(result.data)).toHaveLength(64)
   })
 
@@ -124,7 +124,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => {
       { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 },
     ]
     for (const range of ranges) {
-      const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal()))
+      const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal()))
       expect(failure.code).toBe('gateway/bad-request')
     }
   })
@@ -133,14 +133,14 @@ describe('workspaceFiles.readBytes — defaults and cap', () => {
 describe('workspaceFiles.readBytes — the gates it shares with read', () => {
   it('rejects a directory, a missing path, and an empty path', async () => {
     await mkdir(join(workspace, 'dir'))
-    expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
-    expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
+    expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request')
   })
 
   it('reads a bounded byte window outside the workspace', async () => {
     await writeFile(join(harness.outside, 'sample.bin'), RAMP)
-    const result = await endpoint().readBytes(agent, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal())
+    const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal())
     expect(decode(result.data)).toEqual(RAMP.subarray(2, 6))
     expect(result).toMatchObject({ offset: 2, eof: false })
   })

+ 34 - 34
packages/api/workspace-files/tests/read.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsError } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 let workspace: string
@@ -39,7 +39,7 @@ async function lateNul(): Promise<void> {
 describe('workspaceFiles.read — the happy path', () => {
   it('returns the whole file as one page with its absolute path, version, and byte size', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8')
-    const result = await endpoint().read(agent, 'notes.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'notes.txt', {}, signal())
     expect(result.text).toBe('hello\nworld')
     expect(result.offset).toBe(1)
     expect(result.lines).toBe(2)
@@ -52,19 +52,19 @@ describe('workspaceFiles.read — the happy path', () => {
   it('reads a nested path relative to the workspace root, not to any backend cwd', async () => {
     await mkdir(join(workspace, 'src', 'deep'), { recursive: true })
     await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8')
-    const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal())
+    const result = await endpoint().read(harness.scope, 'src/deep/a.ts', {}, signal())
     expect(result.text).toBe('export {}')
   })
 
   it('returns an empty page for an empty file', async () => {
     await writeFile(join(workspace, 'empty.txt'), '', 'utf8')
-    const result = await endpoint().read(agent, 'empty.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'empty.txt', {}, signal())
     expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 })
   })
 
   it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => {
     await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8')
-    const result = await endpoint().read(agent, 'zh.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'zh.txt', {}, signal())
     expect(result.text).toBe('侧栏')
     expect(result.bytes).toBe(6)
   })
@@ -73,16 +73,16 @@ describe('workspaceFiles.read — the happy path', () => {
 describe('workspaceFiles.read — the line window', () => {
   it('cuts the requested lines and reports that more follow', async () => {
     await twentyLines()
-    const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal())
+    const result = await endpoint().read(harness.scope, 'long.txt', { offset: 6, limit: 3 }, signal())
     expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false })
   })
 
   it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => {
     await twentyLines()
     const service = endpoint()
-    const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal())
+    const exact = await service.read(harness.scope, 'long.txt', { offset: 16, limit: 5 }, signal())
     expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true })
-    const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal())
+    const beyond = await service.read(harness.scope, 'long.txt', { offset: 19, limit: 10 }, signal())
     expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true })
   })
 
@@ -90,21 +90,21 @@ describe('workspaceFiles.read — the line window', () => {
     await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8')
     await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8')
     const service = endpoint()
-    expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
-    expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
+    expect(await service.read(harness.scope, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
+    expect(await service.read(harness.scope, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
     // The third line is empty, not absent: `lines` tells it from a page past the end.
-    expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
+    expect(await service.read(harness.scope, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
   })
 
   it('returns an empty eof page for an offset past the last line', async () => {
     await twentyLines()
-    const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal())
+    const result = await endpoint().read(harness.scope, 'long.txt', { offset: 21 }, signal())
     expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true })
   })
 
   it('defaults the limit to the configured page size', async () => {
     await twentyLines()
-    const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal())
+    const result = await endpoint({ maxLines: 5 }).read(harness.scope, 'long.txt', {}, signal())
     expect(result.text.split('\n')).toHaveLength(5)
     expect(result.eof).toBe(false)
   })
@@ -113,14 +113,14 @@ describe('workspaceFiles.read — the line window', () => {
     await twentyLines()
     const service = endpoint({ maxLines: 5 })
     for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) {
-      const failure = await failureOf(service.read(agent, 'long.txt', range, signal()))
+      const failure = await failureOf(service.read(harness.scope, 'long.txt', range, signal()))
       expect(failure.code).toBe('gateway/bad-request')
     }
   })
 
   it('keeps carriage returns: the page is the file text, not a rendering of it', async () => {
     await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8')
-    const result = await endpoint().read(agent, 'crlf.txt', {}, signal())
+    const result = await endpoint().read(harness.scope, 'crlf.txt', {}, signal())
     expect(result.text).toBe('a\r\nb\r')
   })
 })
@@ -130,7 +130,7 @@ describe('workspaceFiles.read — read access and file kinds', () => {
     await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8')
     const write = vi.spyOn(harness.ctx.fs, 'writeText')
     const edit = vi.spyOn(harness.ctx.fs, 'editText')
-    const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())
+    const result = await endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())
     expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true })
     expect(write).not.toHaveBeenCalled()
     expect(edit).not.toHaveBeenCalled()
@@ -138,14 +138,14 @@ describe('workspaceFiles.read — read access and file kinds', () => {
 
   it('resolves a relative file outside the workspace on the Host', async () => {
     await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
-    expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
+    expect(await endpoint().read(harness.scope, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
   })
 
   it('preserves a filesystem provider refusal for an outside file', async () => {
     await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
     const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED')
     vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal)
-    await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
+    await expect(endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
   })
 
   it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {
@@ -153,7 +153,7 @@ describe('workspaceFiles.read — read access and file kinds', () => {
     // The path itself is inside the workspace and would pass any string
     // comparison; only lstat (before the follow) or realpath containment catches it.
     await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt'))
-    const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'link.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'symlink' })
   })
@@ -161,24 +161,24 @@ describe('workspaceFiles.read — read access and file kinds', () => {
   it('rejects a symlink even when it points back inside the workspace', async () => {
     await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8')
     await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt'))
-    const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'alias.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
   })
 
   it('rejects a directory, which has no text to return', async () => {
     await mkdir(join(workspace, 'src'), { recursive: true })
-    const failure = await failureOf(endpoint().read(agent, 'src', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'src', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'directory' })
   })
 
   it('reports a missing path as not found', async () => {
-    const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'nope.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
   it('refuses an empty path as a bad request', async () => {
-    const failure = await failureOf(endpoint().read(agent, '', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, '', {}, signal()))
     expect(failure.code).toBe('gateway/bad-request')
   })
 })
@@ -186,26 +186,26 @@ describe('workspaceFiles.read — read access and file kinds', () => {
 describe('workspaceFiles.read — gate 3: the page byte cap', () => {
   it('fails a page above the cap rather than returning it shortened', async () => {
     await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8')
-    const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(harness.scope, 'big.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
     expect(failure.details).toMatchObject({ limit: 1024 })
   })
 
   it('accepts a page exactly at the cap, because the cap is inclusive', async () => {
     await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
-    const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal())
+    const result = await endpoint({ maxBytes: 64 }).read(harness.scope, 'exact.txt', {}, signal())
     expect(result.text).toHaveLength(64)
   })
 
   it('counts the newlines between the page lines against the cap', async () => {
     await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
-    const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal()))
+    const failure = await failureOf(endpoint({ maxBytes: 63 }).read(harness.scope, 'exact.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/too-large')
   })
 
   it('caps the page, not the file: a small window of a file far above the cap reads', async () => {
     await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8')
-    const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal())
+    const result = await endpoint({ maxBytes: 1024 }).read(harness.scope, 'huge.txt', { offset: 1990, limit: 3 }, signal())
     expect(result.text.split('\n')).toHaveLength(3)
     expect(result.eof).toBe(false)
     expect(result.bytes).toBeGreaterThan(200_000)
@@ -215,22 +215,22 @@ describe('workspaceFiles.read — gate 3: the page byte cap', () => {
 describe('workspaceFiles.read — gate 4: text only', () => {
   it('rejects bytes that are not valid UTF-8', async () => {
     await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd]))
-    const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'bin.dat', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-text')
   })
 
   it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => {
     await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62]))
     const service = endpoint()
-    expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
+    expect((await failureOf(service.read(harness.scope, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
     // Past the backend's own binary sample, so only the page scan can see it.
     await lateNul()
-    expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
+    expect((await failureOf(service.read(harness.scope, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
   })
 
   it('reads a page that ends before a NUL byte, because detection is per page', async () => {
     await lateNul()
-    const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal())
+    const result = await endpoint().read(harness.scope, 'late-nul.txt', { limit: 1 }, signal())
     expect(result.text).toHaveLength(9000)
     expect(result.eof).toBe(false)
   })
@@ -251,7 +251,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
   it('reports a file deleted after the gate as not found, not as an internal failure', async () => {
     await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
     afterGate(() => rm(join(workspace, 'fleeting.txt')))
-    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-found')
   })
 
@@ -261,7 +261,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
       await rm(join(workspace, 'fleeting.txt'))
       await mkdir(join(workspace, 'fleeting.txt'))
     })
-    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
     expect(failure.code).toBe('workspace-file/not-regular-file')
     expect(failure.details).toMatchObject({ kind: 'directory' })
   })
@@ -269,6 +269,6 @@ describe('workspaceFiles.read — the file changing under its gate', () => {
   it('passes any other backend failure through unchanged', async () => {
     await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8')
     vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR'))
-    await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
+    await expect(endpoint().read(harness.scope, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
   })
 })

+ 75 - 0
packages/api/workspace-files/tests/scope.spec.ts

@@ -0,0 +1,75 @@
+import { resolve } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import { describe, expect, it, vi } from 'vitest'
+import WorkspaceFiles from '../src/index.ts'
+
+const CAPS = {
+  maxBytes: 1024,
+  maxFileBytes: 1024,
+  maxLines: 100,
+  maxEntries: 100,
+}
+
+function header(id: SessionId, cwd?: string): SessionHeader {
+  return {
+    version: SESSION_FORMAT_VERSION,
+    id,
+    createdAt: 1,
+    isSeeded: false,
+    origin: 'subagent',
+    ...cwd === undefined ? {} : { cwd },
+  }
+}
+
+describe('Workspace Files Session scope lookup', () => {
+  it('uses live or stored headers without an Agent and leaves with its plugin', async () => {
+    const liveId = SessionId('live-subagent')
+    const coldId = SessionId('cold-subagent')
+    const fallbackId = SessionId('cold-without-cwd')
+    const missingId = SessionId('missing')
+    const liveRoot = resolve('live-workspace')
+    const coldRoot = resolve('cold-workspace')
+    const fallbackRoot = resolve('fallback-workspace')
+    const stat = vi.fn(async (id: SessionId) => {
+      if (id === coldId) return { header: header(coldId, coldRoot) }
+      if (id === fallbackId) return { header: header(fallbackId) }
+      return undefined
+    })
+    const ctx = new Context()
+    ctx.provide('fs', {} as never)
+    ctx.provide('sandboxPolicy', { workspaceRoot: fallbackRoot } as never)
+    ctx.provide('sessionPersistence', { stat } as never)
+    const sessions = await ctx.plugin(SessionStore)
+    const typert = await ctx.plugin(TypertRegistry)
+    const workspaceFiles = await ctx.plugin(WorkspaceFiles, CAPS)
+
+    try {
+      ctx.sessions.create(liveId, { meta: { cwd: liveRoot, origin: 'subagent' } })
+      expect(ctx.get('agents')).toBeUndefined()
+      const lookup = ctx.typert.lookups.get('workspaceFileScope')
+      expect(lookup).toMatchObject({
+        parameter: 'workspaceFileScope',
+        wire: 'workspaceFileScopeId',
+        hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
+        wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+      })
+      if (lookup === undefined) throw new Error('workspaceFileScope lookup did not register')
+
+      await expect(lookup.resolve(liveId)).resolves.toEqual({ sessionId: liveId, workspaceRoot: liveRoot })
+      expect(stat).not.toHaveBeenCalled()
+      await expect(lookup.resolve(coldId)).resolves.toEqual({ sessionId: coldId, workspaceRoot: coldRoot })
+      await expect(lookup.resolve(fallbackId)).resolves.toEqual({ sessionId: fallbackId, workspaceRoot: fallbackRoot })
+      await expect(lookup.resolve(missingId)).resolves.toBeUndefined()
+      expect(stat.mock.calls.map(([id]) => id)).toEqual([coldId, fallbackId, missingId])
+
+      await workspaceFiles.dispose()
+      expect(ctx.typert.lookups.get('workspaceFileScope')).toBeUndefined()
+    } finally {
+      await workspaceFiles.dispose()
+      await sessions.dispose()
+      await typert.dispose()
+    }
+  })
+})

+ 13 - 13
packages/api/workspace-files/tests/stat.spec.ts

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { FsVersion } from '@deepseek-ai/dsh-fs'
-import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
 
 let harness: Harness
 
@@ -18,7 +18,7 @@ afterEach(async () => {
 describe('workspaceFiles.stat', () => {
   it('returns the absolute path, a version, and the byte size', async () => {
     await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
-    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal())
     expect(result.absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'notes.txt'))))
     expect(result.version.length).toBeGreaterThan(0)
     expect(result.bytes).toBe(6)
@@ -28,11 +28,11 @@ describe('workspaceFiles.stat', () => {
     const path = join(harness.workspace, 'notes.txt')
     await writeFile(path, 'one\n', 'utf8')
     const endpoint = harness.endpoint()
-    const before = await endpoint.stat(agent, 'notes.txt', signal())
-    const page = await endpoint.read(agent, 'notes.txt', {}, signal())
+    const before = await endpoint.stat(harness.scope, 'notes.txt', signal())
+    const page = await endpoint.read(harness.scope, 'notes.txt', {}, signal())
     expect(page.version).toBe(before.version)
     await writeFile(path, 'one\ntwo\n', 'utf8')
-    const after = await endpoint.stat(agent, 'notes.txt', signal())
+    const after = await endpoint.stat(harness.scope, 'notes.txt', signal())
     expect(after.version).not.toBe(before.version)
     expect(after.bytes).toBe(8)
   })
@@ -40,7 +40,7 @@ describe('workspaceFiles.stat', () => {
   it('rejects under a signal the caller already aborted, before any path resolves', async () => {
     const controller = new AbortController()
     controller.abort()
-    await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow()
+    await expect(harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal)).rejects.toThrow()
   })
 
   it('resolves the workspace root and then the file under the caller\'s signal', async () => {
@@ -49,7 +49,7 @@ describe('workspaceFiles.stat', () => {
     const original = fs.resolve.bind(fs)
     const spy = vi.spyOn(fs, 'resolve').mockImplementation((path, opts) => original(path, opts))
     const controller = new AbortController()
-    await harness.endpoint().stat(agent, 'notes.txt', controller.signal)
+    await harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal)
     expect(spy.mock.calls.map(([, opts]) => opts?.signal)).toEqual([controller.signal, controller.signal])
     spy.mockRestore()
   })
@@ -57,7 +57,7 @@ describe('workspaceFiles.stat', () => {
   it('omits bytes when the backend reports no size', async () => {
     await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
     vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
-    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal())
     expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' })
   })
 
@@ -66,13 +66,13 @@ describe('workspaceFiles.stat', () => {
     await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt'))
     await mkdir(join(harness.workspace, 'src'))
     const endpoint = harness.endpoint()
-    expect(await failureOf(endpoint.stat(agent, 'link.txt', signal()))).toMatchObject({
+    expect(await failureOf(endpoint.stat(harness.scope, 'link.txt', signal()))).toMatchObject({
       code: 'workspace-file/not-regular-file',
       details: { kind: 'symlink' },
     })
-    expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
-    expect(await endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 })
-    expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
-    expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request')
+    expect((await failureOf(endpoint.stat(harness.scope, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
+    expect(await endpoint.stat(harness.scope, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 })
+    expect((await failureOf(endpoint.stat(harness.scope, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint.stat(harness.scope, '', signal()))).code).toBe('gateway/bad-request')
   })
 })

+ 3 - 3
packages/api/workspace-files/tsconfig.host.json

@@ -17,9 +17,6 @@
     {
       "path": "../../../vendor/schemastery"
     },
-    {
-      "path": "../../core/agent"
-    },
     {
       "path": "../../core/session"
     },
@@ -29,6 +26,9 @@
     {
       "path": "../../sandbox/sandbox-policy"
     },
+    {
+      "path": "../../session/session-persistence"
+    },
     {
       "path": "../../typert/protocol"
     },

+ 2 - 2
packages/core/session/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/core/session/README.md
-README.md: a06b22b5e8f48743047c68e883edd496fbc6eb77
-README.zh.md: 755894b4c30f0a3807f472cece32685d102efbeb
+README.md: cdcab8ec4d2f6960bb651aff46745b20f91632e2
+README.zh.md: 745615f67084430475e0739b22afa35556b71ee6

+ 1 - 1
packages/core/session/README.md

@@ -181,7 +181,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
 These limits define when the session store needs special care. They are current package constraints, not a task backlog.
 
 - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.
-- **`SESSION_FORMAT_VERSION` names the current V3 logical representation** — the V3 reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the V2→V3 edge converts supported history before constructing `Session`, and write open publishes only the V3 successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
+- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the adjacent migration chain converts supported history before constructing `Session`, and write open publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
 - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
 - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
 

+ 1 - 1
packages/core/session/README.zh.md

@@ -181,7 +181,7 @@ session.deriveMessages()         // the derived model history
 这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
 
 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。
-- **`SESSION_FORMAT_VERSION` 命名当前 V3 逻辑表示**——V3 读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;V2→V3 迁移边在构造 `Session` 前转换受支持的历史,写打开只发布 V3 后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
+- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;相邻迁移链在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
 - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
 

+ 1 - 1
packages/core/session/src/types.ts

@@ -138,7 +138,7 @@ export interface CreateSessionOptions {
   /** Initial replay or fork history supplied at construction. */
   readonly seed?: readonly SessionEvent[]
   /**
-   * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the
+   * Exact fork-inherited prefix length when `meta.isSeeded` is true. The
    * constructor seed is exactly this inherited prefix; the constructor
    * appends the child-owned tagged marker at the cut.
    */

+ 2 - 2
packages/core/system-prompt/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/core/system-prompt/README.md
-README.md: e98c1be171d7dbb16cabf1289a20be8922e5076b
-README.zh.md: 2408ec1dbb730bd5f085f3467a5359f5900efebc
+README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550
+README.zh.md: 55730f11fffdc6ca92ce83613c9af3b1331351b1

Some files were not shown because too many files changed in this diff