Explorar o código

Merge remote-tracking branch 'origin/merge/projcache-v6-compat-into-master' into release/dsh-0.1.2-alpha.5-version-to-master

imccyu hai 2 semanas
pai
achega
cf126d8699
Modificáronse 100 ficheiros con 761 adicións e 474 borrados
  1. 2 2
      .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
  2. 1 0
      .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md
  3. 1 0
      .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
  6. 5 5
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
  7. 5 5
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
  10. 1 1
      .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml
  12. 1 1
      .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md
  13. 1 1
      .agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md
  14. 2 2
      .agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml
  15. 13 36
      .agents/notes/implemented/architecture/2026-08-05-session-preparation.md
  16. 13 36
      .agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml
  18. 10 10
      .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
  19. 10 10
      .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml
  21. 2 2
      .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md
  23. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  24. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  25. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  26. 2 2
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml
  27. 4 2
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
  28. 4 2
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md
  29. 2 2
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
  30. 3 3
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
  31. 3 3
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
  32. 3 3
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml
  33. 42 0
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md
  34. 42 0
      .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md
  35. 2 2
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml
  36. 1 1
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md
  37. 1 1
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md
  38. 6 0
      .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml
  39. 33 0
      .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md
  40. 33 0
      .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md
  41. 2 2
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml
  42. 13 8
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
  43. 13 8
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md
  44. 0 38
      .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md
  45. 0 38
      .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md
  46. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml
  47. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
  48. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md
  49. 0 40
      .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md
  50. 0 40
      .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md
  51. 2 2
      .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml
  52. 5 7
      .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md
  53. 5 7
      .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md
  54. 3 3
      .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml
  55. 29 0
      .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md
  56. 29 0
      .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md
  57. 2 2
      .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml
  58. 12 34
      .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
  59. 12 34
      .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md
  60. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml
  61. 4 0
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
  62. 4 0
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
  63. 2 2
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml
  64. 1 1
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md
  65. 1 1
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md
  66. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
  67. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
  68. 3 3
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
  69. 6 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml
  70. 57 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md
  71. 57 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md
  72. 2 2
      .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml
  73. 1 1
      .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md
  74. 1 1
      .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md
  75. 2 2
      .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml
  76. 1 1
      .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md
  77. 1 1
      .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md
  78. 2 2
      .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml
  79. 8 6
      .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md
  80. 8 6
      .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md
  81. 2 2
      .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml
  82. 1 1
      .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md
  83. 1 1
      .agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md
  84. 6 0
      .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml
  85. 46 0
      .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md
  86. 46 0
      .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md
  87. 2 2
      .agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.i18n.yaml
  88. 1 1
      .agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md
  89. 1 1
      .agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md
  90. 2 2
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
  91. 3 3
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
  92. 3 3
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
  93. 6 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml
  94. 35 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md
  95. 35 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md
  96. 2 2
      .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml
  97. 0 1
      .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md
  98. 0 1
      .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md
  99. 2 2
      .agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml
  100. 1 1
      .agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml → .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.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-18-shared-persistence-write-coordinator.md
-2026-06-18-shared-persistence-write-coordinator.md: a61ceb9b2197a6dd8ed86c1c971373a2706607aa
-2026-06-18-shared-persistence-write-coordinator.zh.md: 777d5f5972ac1096c2e3434f9e0ac5aec27e8c26
+2026-06-18-shared-persistence-write-coordinator.md: 5c324f2c0c2b951f664bcf92725a36c4975fd3a1
+2026-06-18-shared-persistence-write-coordinator.zh.md: 51ef76189cb9276214bf05bbd1dbd8e3755daa35

+ 1 - 0
.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md → .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md

@@ -1,6 +1,7 @@
 # Agent Note: Shared persistence write coordinator
 
 Status: implemented
+Archived: 2026-08-31
 
 English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md)
 

+ 1 - 0
.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md → .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 共享持久化写入协调器
 
 Status: implemented
+Archived: 2026-08-31
 
 [English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
 

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -10,6 +10,9 @@
     "architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02",
     "architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85",
     "architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa",
+    "architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml": "sha256:3c5c22e9e6a63598ba648cad46d783af322cf3afd6021426a2d738f4b026bf65",
+    "architecture/2026-06-18-shared-persistence-write-coordinator.md": "sha256:d5242c770101086b6f0a0c40eab500d405ef4a98cae07e28d9ec21e89d94f90e",
+    "architecture/2026-06-18-shared-persistence-write-coordinator.zh.md": "sha256:3dce52e302600a0eea29b4821a2718b6bbc1ebe4c6c2ae372cd0cbe66cb05519",
     "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4",
     "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce",
     "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff",

+ 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: 50ec79de83f0cef4a3ec94b689cc25937e334016
-2026-06-14-session-persistence.zh.md: 7b66aed6f077ac484802cfa1e23e1ba7ac3ae985
+2026-06-14-session-persistence.md: a7e06af78c4a372be7a68f3e0f6dc18e38cbead1
+2026-06-14-session-persistence.zh.md: 6d458d4f4c31793212d674bb406204c3882a25ed

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

@@ -14,22 +14,22 @@ 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: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
+1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`export`, 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. Eligible `assistant/chunk` delta runs use packed rows by default; [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 `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
-- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.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, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` 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 and coordinator remain 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.
+- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.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](../simplification/2026-06-19-drop-mutable-session-summary.md).)
-- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
+- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` opens the session's write handle, reads the stored log, and publishes the prepared Session under the persisted id, continuing its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns the unpublished-Session ownership window. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
 
 ## Alternatives considered
 
 Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **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`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
+Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no compatibility promise: reads validate current v0 records only, and retired same-version shapes refuse fail-closed ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
 
 ## Consequences
 

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

@@ -14,22 +14,22 @@ Status: implemented
 
 持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
 
-1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
+1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`export`,其中 `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` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
 
 长期有效、存在争议的关键选择:
 
 - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
-- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
-- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
+- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.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 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。)
-- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
+- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 打开该会话的写句柄,读取已存储的日志,并以持久化 id 发布准备好的 Session,继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义未发布 Session 的所有权窗口。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。
 
 ## 曾考虑的替代方案
 
 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
 
-格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定
+格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不作兼容承诺:读取只校验当前 v0 记录,已废弃的同版本形态会以 fail-closed 方式拒绝([导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.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-19-zstandard-jsonl-session-logs.md
-2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254
-2026-07-19-zstandard-jsonl-session-logs.zh.md: d58f89430ab91de6beabba83c2a31f43e4a7d275
+2026-07-19-zstandard-jsonl-session-logs.md: 33486251a8b018cda61a2845a55218f13c38072b
+2026-07-19-zstandard-jsonl-session-logs.zh.md: 5e6b4a1ed7cf5869e1700c2884898b9899452a30

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md

@@ -32,7 +32,7 @@ A frame-boundary scanner reads the standard magic, variable header fields, block
 
 Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs.
 
-EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
+EOF inside the final frame is a torn tail. The frame belongs to an append that never resolved, so none of its records were acknowledged durable: repair truncates from that frame's starting byte, retains all prior complete frames, and appends the coordinator's synthetic tool, step, and turn closers as one new checksummed frame ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns dropping the earlier partial-plaintext salvage).
 
 ### Consumers and verification
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md

@@ -32,7 +32,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
 
 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。
 
-最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整且以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧
+最终帧内部遇到 EOF 属于撕裂尾部。该帧属于一次从未完成结算的追加,因此其中没有任何记录被确认为持久:修复从该帧起始字节截断,保留此前全部完整帧,并把协调器生成的工具、步骤与轮次闭合事件作为一个新的带校验和帧追加(对早先部分明文抢救路径的移除由[导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)
 
 ### 消费方与验证
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-project-session-directories.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-24-project-session-directories.md
-2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2
-2026-07-24-project-session-directories.zh.md: 932b1d29c41d2a854abfc0bab0e47a0ff8c96fe9
+2026-07-24-project-session-directories.md: a37f9231167822e409308f8da60f6c1e837c74d5
+2026-07-24-project-session-directories.zh.md: 469567764219d7baabea89bd94aecd81bd0e5ab3

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md

@@ -29,7 +29,7 @@ Case-insensitive filesystems can also make differently cased project keys refer
 
 The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
 
-The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
+The encoded session id names an ownership directory rather than the transcript itself. The backend's diagnostics-only `locate` hook resolves the fixed transcript path inside it for format-refusal messages ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns removing the consumer-facing path query). Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
 
 Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `<project>/<id>.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md

@@ -29,7 +29,7 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立
 
 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
 
-编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
+编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。后端仅供诊断的 `locate` 钩子在其中解析固定的 transcript 路径,供格式拒绝消息使用(移除面向消费者的路径查询由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
 
 延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `<project>/<id>.jsonl*` 产物;预发布格式不提供自动数据迁移。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-session-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-08-05-session-preparation.md
-2026-08-05-session-preparation.md: 50f1ea38e671c6aa7b0f4adaf2fecbf83decc23c
-2026-08-05-session-preparation.zh.md: cd918d126b56b081bcc1b6aa43a10d662668102d
+2026-08-05-session-preparation.md: 040c9f788173a7cedd91be33cbe7ced3ca758a06
+2026-08-05-session-preparation.zh.md: 44b609d488c6f8bb406370f9097eb2a085f7cc2b

+ 13 - 36
.agents/notes/implemented/architecture/2026-08-05-session-preparation.md

@@ -6,63 +6,40 @@ English | [中文](2026-08-05-session-preparation.zh.md)
 
 ## Problem
 
-Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point.
+Fresh creation and persisted resume reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together.
 
-Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together.
+Cold history inspection and Agent resume also independently materialized the same persisted session log, which this note originally answered with a persistence-side prepared-Session cache; that half is superseded below.
 
 ## Decision
 
-`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`.
+`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume reads the stored log through the session's write handle, appends `interruptedTurnClosers`, and wraps `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })` — the restoration branch that validates and freezes the transferred graphs in place.
 
 The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior.
 
 This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-contracts.md) without replacing its ownership model.
 
-## Persisted preparation lifecycle
+## Superseded: the persistence-side preparation lifecycle
 
-A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence.
-
-`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release.
-
-`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation.
-
-The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md).
-
-## History and resume reuse
-
-History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again.
-
-If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view.
-
-Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read.
+This note originally also gave persistence a `prepare(id)`/`inspect(id)` lifecycle: a coordinator-backed bounded LRU of cold unpublished Sessions with exclusive reservations, revision-checked reuse, and repair committed inside `prepare`/`load`, so history pagination and a later resume shared one cold materialization. The [handle-based persistence seam](2026-08-27-handle-based-session-persistence.md) deletes all of it: persistence exposes handles only, resume reads the log through its write handle and owns repair, and read-only observers (session-query) own their cold-Session cache keyed by the `stat().revision` change token. The read-reuse goal survives in that cache; the exclusive-reservation machinery does not, because the write handle's single-writer ownership is the exclusion resume actually needs. Resume pays one whole-log read through the handle where the prepared cache sometimes served a warm Session — an accepted cost recorded in the handle note.
 
 ## Boundaries
 
-- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation, synthesizes logical closers, or joins the LRU.
-- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted.
-- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity.
-- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject.
-- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation.
-- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation.
+- The preparation is one disposable ownership window, not a cache: disposal is synchronous and idempotent, and publication accepts only the exact prepared Session.
+- A fresh create never claims a persisted identity implicitly. Persistence collisions continue to reject (`SessionAlreadyExistsError`, `SessionAlreadyOwnedError`).
+- Live Sessions are owned by the existing stores; preparations hold only unpublished ones.
 
 ## Verification
 
-The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
+Agent-loop tests pin the common publication pipeline across create, `createAgent`, and resume, including rollback on setup failure, cancellation, and teardown, and that disposal releases the write handle (reopening for write succeeds). Session-store tests pin the restoration branch's validate-and-freeze-in-place transfer.
 
 ## Alternatives considered
 
-**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle.
-
-**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit.
+**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. This rationale still guards the session-query cold cache: observation never creates an Agent.
 
-**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store.
+**Cache only `{ meta, events }`.** Rejected at the time because resume would still reconstruct a Session from the cached values. Under the handle seam this is exactly what the read side does — session-query caches a cold Session per revision for reads only — while resume rebuilds from the handle read, trading the warm-Session reuse for a single write-ownership door.
 
-**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary.
-
-**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics.
+**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading and Session construction are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary; the handle seam kept that split while moving repair to the loop's resume path.
 
 ## Consequences
 
-One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities.
-
-The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization.
+Create and resume share one publication protocol without merging Agent and Session responsibilities, and every exit path disposes exactly one preparation. The persistence-side reuse consequences originally recorded here (shared cold materialization, LRU bounds, reservation coordination) now belong to the [handle note](2026-08-27-handle-based-session-persistence.md) and the session-query cache that replaced them.

+ 13 - 36
.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md

@@ -6,63 +6,40 @@ Status: implemented
 
 ## 问题
 
-冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合
+新建和持久化恢复通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent
 
-新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent
+冷历史检查和 agent(智能体)恢复也曾分别实体化同一份持久会话日志,本 Note 最初以持久化侧的已准备 Session 缓存回答了这一半问题;那一半已在下文中被取代
 
 ## 决策
 
-`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象
+`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复通过该会话的写句柄读取已存储的日志、追加 `interruptedTurnClosers`,再包装 `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })`——即就地验证并冻结转移对象图的恢复分支
 
 agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上对准备对象执行 dispose(资源释放)。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。
 
 该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md)中的发布边界,但不替换其所有权模型。
 
-## 持久化准备生命周期
+## 已被取代:持久化侧的准备生命周期
 
-使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与已配平的逻辑事件日志构成读取方借用的 `SessionInspection`,revision 则保留在持久化内部。
-
-`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。
-
-`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。
-
-存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.zh.md)所规定的存储与恢复规则。
-
-## 历史与恢复复用
-
-历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。
-
-如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。
-
-冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.zh.md)中的生命周期与授权规则,也消除了重复冷读。
+本 Note 最初还赋予持久化一个 `prepare(id)`/`inspect(id)` 生命周期:由协调器支撑的、装有冷未发布 Session 的有界 LRU,带独占预留、按 revision 校验的复用,以及在 `prepare`/`load` 内部提交的修复,使历史分页与后续恢复共享一次冷实体化。[基于句柄的持久化 seam](2026-08-27-handle-based-session-persistence.zh.md) 删除了这一切:持久化只暴露句柄,恢复通过其写句柄读取日志并自行负责修复,只读观察方(session-query)拥有自己的冷 Session 缓存,以 `stat().revision` 变更令牌为键。读取复用的目标在该缓存中得以延续;独占预留机制则没有延续,因为写句柄的单写者所有权正是恢复真正需要的排他手段。在已准备缓存有时能提供温 Session 的场景下,恢复要为通过句柄的一次全日志读取付出代价——这是句柄 Note 中记录的、已被接受的成本。
 
 ## 边界
 
-- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU。
-- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。
-- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。
-- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。
-- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。
-- Revision 校验在复用点和修复提交点建立新鲜度,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。
+- 准备对象是一个可 dispose 的所有权窗口,而不是缓存:dispose 同步且幂等,发布只接受精确的已准备 Session。
+- 新建流程绝不隐式认领持久化身份。持久化冲突仍会被拒绝(`SessionAlreadyExistsError`、`SessionAlreadyOwnedError`)。
+- 实时 Session 由现有存储持有;准备对象只持有未发布的 Session。
 
 ## 验证
 
-共享持久化约定规定冷检查不得修改存储且须保持配平,并覆盖后续修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和清理期间从检查到恢复的路径
+agent loop 测试覆盖 create、`createAgent` 与 resume 之间的统一发布流水线,包括设置失败时的回滚、取消与清理,以及 dispose 会释放写句柄(重新以写模式打开可以成功)。Session store 测试覆盖恢复分支的就地验证并冻结的所有权转移。
 
 ## 考虑过的替代方案
 
-**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。
-
-**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。
+**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。该理由仍然守护着 session-query 冷缓存:观察绝不创建 agent。
 
-**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复
+**只缓存 `{ meta, events }`。** 当时不采用,因为恢复仍需从缓存值重新构造 Session。在句柄 seam 下,这恰好是读取侧的做法——session-query 按 revision 为只读用途缓存一个冷 Session——而恢复则从句柄读取重建,以温 Session 复用换取唯一的写所有权之门。
 
-**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。
-
-**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。
+**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读与 Session 构造属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界;句柄 seam 保留了这一分工,同时把修复移入循环的恢复路径。
 
 ## 后果
 
-一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。
-
-首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。
+新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离,且每条退出路径恰好 dispose 一个准备对象。本 Note 最初记录的持久化侧复用后果(共享冷实体化、LRU 上限、预留协调)如今归属于[句柄 Note](2026-08-27-handle-based-session-persistence.zh.md) 以及取代它们的 session-query 缓存。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.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-08-bounded-session-persistence-write-batching.md
-2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb
-2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3
+2026-08-08-bounded-session-persistence-write-batching.md: 6fb44e494fc17bde08eb3132afe42ce73b5a4e47
+2026-08-08-bounded-session-persistence-write-batching.zh.md: 576764583dd8c465ae45866f62ef15773735961e

+ 10 - 10
.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md

@@ -20,19 +20,19 @@ The scheduling bound is deterministic. With an immediately resolving sink, the f
 
 ## Decision
 
-The JSONL provider exposes `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. The provider resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior.
+The fixed window is the JSONL provider's constant `LIVE_WRITE_BATCH_MAX_DELAY_MS` (200 ms), an internal scheduling policy rather than configuration: the backend's own session listeners route live events by id into the active write handle's buffer, so batching never crosses the package boundary ([handle note](2026-08-27-handle-based-session-persistence.md)).
 
-Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it.
+Each active write handle owns its buffer directly. A routed event lands in the handle's pending array, and the first event of an idle buffer arms one fixed timer. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, a single-flight drain persists the pending prefix through the handle's mutation chain, which already serializes it against explicit appends. Events admitted during a drain pass coalesce into the next chained batch, in order.
 
-`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA.
+The window bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA.
 
-`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
+`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement (`session/disposed`), the handle's close, and backend teardown's close sweep use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
 
 Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format.
 
-A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
+A failed background drain retains its complete batch in order ahead of newer pending events, reports the failure once, and pauses the automatic timer. The next explicit drain — a `session/flush` barrier, service-level `flush()`, or close — retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
 
-This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary.
+This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one buffer owner per live Session, retained failed batches, retirement, and quiescent disposal. The coordinator and the separate write-behind controller that first hosted this behavior are deleted; the buffer, timer, and drain live on the provider's handle, and the [handle-based seam](2026-08-27-handle-based-session-persistence.md) owns the storage boundary they write through.
 
 ## Alternatives considered
 
@@ -42,11 +42,11 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live
 
 **Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait.
 
-**Implement the timer inside JSONL.** Rejected: scheduling, failure retention, flush races, and teardown are provider-neutral lifecycle concerns that belong in `PersistenceCoordinator`; an out-of-tree provider can reuse the same behavior.
+**A shared provider-neutral controller component.** Rejected after one iteration shipped it: the handle's mutation chain already serializes writes, so a separate controller duplicated that ordering machinery. Each provider implements the buffer on its own handle, and the shared live-write contract suite pins the equivalent observable behavior for any provider.
 
 ## Verification
 
-The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL suite retains storage-format, recovery, and shared persistence-contract coverage.
+The shared live-write contract suite (`runLiveWritePathContract`) uses a fake clock to prove the fixed, non-resetting 200 ms window; the `session/flush` barrier and its loud failure surfacing; ordered failure retention with exactly-once recovery; the service-level `flush()` sweep with per-session failure aggregation; and the disposed/close/teardown drains. The JSONL suite retains its storage-format, recovery, and shared persistence-contract coverage.
 
 ## Consequences
 
@@ -54,6 +54,6 @@ High-frequency event bursts normally produce fewer durable append operations whi
 
 This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule.
 
-An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait.
+An admitted event can remain only in memory during the fixed window, and then while scheduling or backend work is outstanding. Explicit durability boundaries remain unchanged and bypass the wait.
 
-The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged.
+The handle gives the timer, active drain, pending prefix, retry pause, and barrier one owner; the backend's listeners own routing and lifecycle-driven drains. `SESSION_FORMAT_VERSION` remains unchanged.

+ 10 - 10
.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md

@@ -20,19 +20,19 @@ JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fs
 
 ## 决策
 
-JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。provider 在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责
+固定窗口是 JSONL provider 的常量 `LIVE_WRITE_BATCH_MAX_DELAY_MS`(200 ms),它是内部调度策略而非配置:后端自己的会话监听器按 id 把实时事件路由进活跃写句柄的缓冲,因此批处理绝不跨越包边界([句柄 Note](2026-08-27-handle-based-session-persistence.zh.md))
 
-每个活跃的会话都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一会话同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入
+每个活跃写句柄直接拥有自己的缓冲。被路由的事件落入句柄的待处理数组,空闲缓冲收到的第一个事件会启动一个固定计时器。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,一次 single-flight 排空会把待处理前缀经由句柄的修改链持久化,该链本就将其与显式 append 串行化。排空进行期间接纳的事件会按顺序合并进下一个链上的批次
 
-`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
+该窗口只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
 
-`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
+`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役(`session/disposed`)、句柄的 close 与后端 teardown 的关闭清扫共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
 
 每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。
 
-后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
+后台排空失败后,其完整批次会按顺序保留在所有较新的待处理事件之前,该失败被报告一次,自动计时器随之暂停。下一次显式排空——`session/flush` 屏障、服务级 `flush()` 或 close——会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
 
-本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md)定义。
+本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个缓冲所有者、保留失败批次、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。最初承载该行为的协调器与独立的 write-behind 控制器均已删除;缓冲、计时器和排空落在 provider 的句柄上,它们写入所经过的存储边界由[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md) 定义。
 
 ## 备选方案
 
@@ -42,11 +42,11 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
 
 **按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。
 
-**在 JSONL 内实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是 provider 无关的生命周期问题,属于 `PersistenceCoordinator`;仓库外 provider 可以复用同一行为。
+**共享的 provider 无关控制器组件。** 曾在一次迭代中交付,随后不采纳:句柄的修改链本就串行化写入,独立控制器重复了这套排序机制。每个 provider 在自己的句柄上实现该缓冲,共享的实时写入约定测试套件为任何 provider 钉住等价的可观察行为。
 
 ## 验证
 
-控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。
+共享的实时写入约定测试套件(`runLiveWritePathContract`)使用假时钟证明固定且不会重置的 200 ms 窗口、`session/flush` 屏障及其失败的响亮暴露、有序保留失败批次并恰好恢复一次、带逐会话失败聚合的服务级 `flush()` 清扫,以及 disposed/close/teardown 的排空。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。
 
 ## 后果
 
@@ -54,6 +54,6 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
 
 本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。
 
-接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。
+接纳后的事件在固定窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。显式持久性边界保持不变,并会绕过等待。
 
-deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。
+句柄统一负责计时器、活跃排空、待处理前缀、重试暂停和屏障;后端的监听器负责路由和生命周期驱动的排空。`SESSION_FORMAT_VERSION` 保持不变。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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-message-feedback-sidecar.md
-2026-08-10-message-feedback-sidecar.md: eb1f8786f9ec0363b3c98d5b80a796b9c0fc0b4c
-2026-08-10-message-feedback-sidecar.zh.md: 573d3b30e3bd14b492925f4a00424db588736943
+2026-08-10-message-feedback-sidecar.md: d047bebf47f844a6d88932c7e19a3952f43d94cc
+2026-08-10-message-feedback-sidecar.zh.md: 4ee1b861a8013dacfd5eba40d9e30b23237be2e5

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md

@@ -16,9 +16,9 @@ A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes w
 
 Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it.
 
-`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization.
+`put` accepts a target only when the observed log — a live owner's in-memory events, else the durable log through a persistence read handle — contains a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Observation is cold-safe: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `stat()` preflight classifies definite absence; a read failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization.
 
-Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
+Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically re-read from sequence zero through a fresh persistence read handle. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
 
 Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md

@@ -16,9 +16,9 @@ Status: implemented
 
 每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。
 
-`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。
+`put` 只在被观测的日志——live 持有者的内存事件,否则是经由持久化读句柄读取的持久日志——包含非空、append-origin 且 `MessageId` 与目标相同的 `assistant/message` 时才接受该目标。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。观测是 cold-safe 的:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `stat()` 预检明确不存在;已进入目录的 Session 若读取失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。
 
-`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
+`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过新开的持久化读句柄从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
 
 每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f
-2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10
+2026-08-18-session-history-and-event-transport.md: 10fdd9b256c27aadada97195c8dc5516b4485a43
+2026-08-18-session-history-and-event-transport.zh.md: bf110b5f1a2bea99f9aa086c66a616eddaa0a50e

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md

@@ -163,7 +163,7 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
 
 Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup.
 
-`SessionQuery.observeSession()` chooses an attached Session or borrows one prepared source from `SessionPersistence.borrowSession()`. The persistence preparation cache shares concurrent cold reads and pins the exact unpublished Session until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state.
+`SessionQuery.observeSession()` chooses an attached Session or serves a cold one from the reader's own prepared cache, filled through a persistence read handle. The cache shares concurrent cold reads and pins an entry until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state.
 
 `session.list` never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md

@@ -163,7 +163,7 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
 
 读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。
 
-`SessionQuery.observeSession()` 选择 attached Session,或从 `SessionPersistence.borrowSession()` 借用 prepared source。Persistence preparation cache 共享并发冷读取,并在所有 observation lease 释放前固定同一个未发布 Session。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。
+`SessionQuery.observeSession()` 选择 attached Session,或从读取方自己的 prepared cache——经由持久化读句柄填充——提供冷 Session。该 cache 共享并发冷读取,并在所有 observation lease 释放前固定同一条目。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。
 
 `session.list` 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
-2026-08-19-projection-cache-per-session-files.md: 9e102e786a6c06d82d1a0f45cc2f96a50c8abcd8
-2026-08-19-projection-cache-per-session-files.zh.md: d875c3f57800936f66fbf65233637df9bf300e2d
+2026-08-19-projection-cache-per-session-files.md: c0ead55557e45905cfd368c70d826bf0c3eaade3
+2026-08-19-projection-cache-per-session-files.zh.md: e5582d14d68c1aed76ef8aab08a8d575e8a1842d

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

@@ -20,8 +20,9 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy
 - Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column.
 - ACP, headless, SDK, and Web sessions publish cache rows for later consumers. The log-leading durability barrier may flush a covered prefix at the cache cadence and split otherwise coalesced physical JSONL runs; recorded profile snapshots re-pack the logical event stream so cache timing does not define fixture layout.
 - The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain.
-- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The legacy file remains untouched.
-- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id.
+- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path, the legacy unit name matches, and its version is current or declared compatible. A version outside that accepted set remains untouched and the new domain opens empty; storage never relabels a version the domain owner did not approve. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The [cross-version read-compatibility decision](2026-09-02-projcache-cross-version-read-compat.md) owns the version policy.
+- The `session_projcache` domain uses version 6 and declares versions 3, 4, and 5 compatible. Vouched-for records retain their listing projections across upgrades, absent lineage fields normalize to an unseeded identity, and a seeded caller rejects that identity and refolds cold. A record that still fails schema validation is backed up and skipped; every subsequent write stamps version 6.
+- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd, isSeeded, inheritedEventCount}` identity guards against a recreated id or a mismatched inherited prefix.
 
 ## Alternatives considered
 
@@ -29,3 +30,4 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy
 - **Cache-owned per-session files** (`<root>/<session-id>/projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent.
 - **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout.
 - **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded.
+- **Copy legacy values across undeclared unit versions.** Rejected: the json backend does not know a domain's record schema and cannot derive session-lineage fields. It copies an older record only when the domain explicitly lists that version in `compatibleVersions` and its current schema accepts the value; otherwise the record stays untouched and reads as absent.

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

@@ -20,8 +20,9 @@ Status: implemented
 - 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。
 - ACP、headless、SDK 与 Web 会话都会发布缓存行,供后续消费方使用。确保日志领先的持久性屏障可能按缓存节奏 flush 已覆盖的前缀,并拆分原本会合并的物理 JSONL 行;各 profile 的录制快照会重新 pack 逻辑事件流,因此缓存时序不会决定 fixture 布局。
 - per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。
-- json 后端仅在枚举时没有发现任何新布局文档路径,才从旧整单元缓存引导 per-record 目录树。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。旧文件保持不变。
-- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。
+- json 后端仅在枚举时没有发现任何新布局文档路径、旧单元名称匹配,且其版本为当前版本或已声明兼容版本时,才从旧整单元缓存引导 per-record 目录树。接受集合之外的版本保持不变,新域为空;存储绝不把域 owner 未批准的版本改标为当前版本。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。[跨版本读兼容决策](2026-09-02-projcache-cross-version-read-compat.zh.md)是版本策略的权威说明。
+- `session_projcache` 域使用版本 6,并声明版本 3、4、5 兼容。经背书的记录在升级后保留列表投影;缺失的 lineage 字段归一化为 unseeded 身份,seeded 调用方会拒绝该身份并回落冷折叠。仍然通不过 schema 校验的记录会被备份并跳过;后续每次写入都使用版本 6。
+- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd, isSeeded, inheritedEventCount}` 身份防止被重建的 id 或不匹配的继承前缀误导。
 
 ## Alternatives considered
 
@@ -29,3 +30,4 @@ Status: implemented
 - **缓存自持的每会话文件**(`<root>/<session-id>/projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。
 - **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。
 - **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。
+- **跨未声明单元版本复制旧值。** 未采用:json 后端不知道域的记录 schema,也无法推导会话 lineage 字段。只有当域在 `compatibleVersions` 中明确列出旧版本,且当前 schema 接受该值时,后端才复制旧记录;否则记录保持不变并读作不存在。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md
-2026-08-25-session-observations-and-projection-owned-client-state.md: 492640385215b059761b17a057328cc5c6d24bff
-2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0b892a9cae2c999b4472dd46f19068e2b4139e60
+2026-08-25-session-observations-and-projection-owned-client-state.md: 554d003da1e1767b8955be7a067cbea716f130ef
+2026-08-25-session-observations-and-projection-owned-client-state.zh.md: a63b0414f8b0250e4cea95ac39a39aa2c62cd958

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md

@@ -32,8 +32,8 @@ flowchart LR
   Cache -->|"small miss"| Observe
   Observe --> Source{"live or cold"}
   Source --> Live["attached Session cut"]
-  Source --> Borrow["borrowSession"]
-  Borrow --> Prepared["SessionPreparations.borrow"]
+  Source --> Borrow["persistence read handle"]
+  Borrow --> Prepared["reader's prepared cache"]
   Live --> Mode{"all or none"}
   Prepared --> Mode
   Mode --> Snapshot["SessionObservation"]
@@ -45,7 +45,7 @@ flowchart LR
 
 ### Observation is the point-read unit
 
-`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise `SessionPersistence.borrowSession()` and `SessionPreparations.borrow()` share and pin one prepared Session, including an in-flight cold load.
+`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load.
 
 Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md

@@ -32,8 +32,8 @@ flowchart LR
   Cache -->|"small miss"| Observe
   Observe --> Source{"live or cold"}
   Source --> Live["attached Session cut"]
-  Source --> Borrow["borrowSession"]
-  Borrow --> Prepared["SessionPreparations.borrow"]
+  Source --> Borrow["persistence read handle"]
+  Borrow --> Prepared["reader's prepared cache"]
   Live --> Mode{"all or none"}
   Prepared --> Mode
   Mode --> Snapshot["SessionObservation"]
@@ -45,7 +45,7 @@ flowchart LR
 
 ### Observation 是 point read 单元
 
-`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则 `SessionPersistence.borrowSession()` 与 `SessionPreparations.borrow()` 共享并固定一份 prepared Session,包括尚未完成的冷加载。
+`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。
 
 每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。
 

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml → .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md
-2026-07-28-load-pre-identity-session-messages.md: 6d022cb4b37345cd61cc9a89c6fc55c19ad402a5
-2026-07-28-load-pre-identity-session-messages.zh.md: 86439337b3c646a72b7584fbf4640799226fc9ca
+#   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: ced7a78178d3036fd5fa9a09ca49f8c751c3a169
+2026-08-27-handle-based-session-persistence.zh.md: e13b5b0e9e7286411b6d38f3b9dc0e86fa742a3d

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

@@ -0,0 +1,42 @@
+# Agent Note: Handle-based session persistence
+
+Status: implemented
+
+English | [中文](2026-08-27-handle-based-session-persistence.zh.md)
+
+## Problem
+
+The previous persistence seam owned far more than storage. A shared coordinator subscribed to `session/created`/`session/event`/`session/flush`/`session/disposed` and adopted any published session (ownerless claims, HMR re-seeding, stored-prefix adoption); a bounded prepared-Session LRU with exclusive reservations served resume and read-only observation from one cache; committing crash repair lived inside `load`/`prepare`; and optimistic revision read/check/read loops stood in for ownership, so a continuous external writer could livelock a read and nothing excluded a second writer. The service surface (twelve methods) mixed storage with Session construction and lifecycle. Cross-process write ownership — the next step — has no honest home in that shape: ownership belongs to an explicit per-session channel with an owner, not to a global listener.
+
+## Decision
+
+**The seam is five service methods returning or serving per-session handles.** `create(header)` stores a new session and returns its owned write handle; `open(id, 'read' | 'write')` opens an existing one; `stat(id)`/`list()` observe snapshots (`header`, opaque `revision`, optional `eventCount`/`sizeBytes` hints — the JSONL backend supplies `sizeBytes`) without reading logs; service-level `flush()` is one backend-wide durability barrier that drains and flushes every active write handle, aggregating per-session failures without abandoning the sweep. The seam carries no raw-artifact export: the WebUI ZIP download serializes the logical log (header line + events) from a read handle in `dsh-session-log-export`, so every backend exports identically and the JSONL-only 501 path is gone. A `SessionHandle` carries `read(offset?, length?)` (validated contiguous prefix slices, never a torn tail, monotonic per handle), `append` (contiguous; persistence is best-effort on resolution, and the shipped JSONL backend happens to persist each batch immediately), `flush` (the durability barrier, which also materializes an empty session), and idempotent uncancellable `close`. One handle type serves both accesses — a mutation on a read handle is a runtime `SessionReadOnlyError`, the deliberate convention of this codebase's other seams rather than a typed split. Single-writer ownership is enforced in-process by a registry (`SessionAlreadyOwnedError`); the durable cross-process lease is the planned next layer on the same shape.
+
+**The agent lifecycle owns handle acquisition; the backend owns the event-driven flow.** agent-loop — the sole production publication point for sessions — acquires the handle before publication (`create` for fresh sessions, appending any constructor seed through it; `open(id, 'write')` for resume) and closes it in the same memoized teardown that drains the loop. Because persistence already enforces one active write handle per session id, the backend installs the session listeners once and routes by id: `session/event` into the owning handle's bounded write-behind window (an internal scheduling policy, not configuration), `session/flush` as the durability and error-observation barrier, `session/disposed` as final drain and close. Nothing about the write path crosses the package boundary — no writer component, no batching configuration, no drain registry. Root-fiber disposal runs every fiber's disposers concurrently, so `close()` itself drains the routed buffer through the still-open storage; backend teardown's close sweep keeps application shutdown lossless regardless of which fiber unwinds first. The drain guarantees only buffered already-emitted events; a root dispose mid-turn still loses the turn's unemitted remainder by design — the next resume's `interruptedTurnClosers` repairs that tail durably. Sessions published outside the lifecycle no longer persist implicitly; nothing in production does that.
+
+**Semantic crash repair moved out of persistence.** Resume reads the physically valid log through its write handle, computes `interruptedTurnClosers`, and appends them (plus the constructor's `session/end-seed` marker) through the same handle as ordinary batches — repair is not a special storage entry point. Read-only observers (session-query) balance an interrupted cold log in memory only, and own their cold-Session cache keyed by `stat().revision`; the persistence-side prepared cache and revision convergence loops are deleted.
+
+**Visibility and freshness are explicit.** A created session is observable in-process from `create`; physical materialization may be deferred (a pure optimization) until the first append or flush, other processes see only materialized sessions, and a crash before materialization means the session never existed. Once an append or flush resolves, reads started afterwards on the same backend instance observe at least that prefix — the guarantee `message-feedback`'s durable-target check rides on.
+
+**Revision simplifies to a per-instance change token.** Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and `sizeBytes` from one `fs.stat`; a backend whose medium can count events cheaply may supply the `eventCount` hint instead. The session-list cold blank probe returns on this metadata (`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`), restoring the capability removed with the path query.
+
+## Alternatives considered
+
+**Typed read/write handle classes (or overloads).** Rejected as the seam style: this codebase's seams prefer one access-tagged type with runtime refusal, and the split would double every consumer-facing type for one compile-time check.
+
+**Keeping the coordinator's adoption/HMR write path beside handles.** Rejected: adoption exists to guess ownership after the fact; with the lifecycle handing the handle over explicitly, a reloaded backend that cannot serve old handles fails the writer loudly instead of silently re-claiming logs, and a session with no handle is a composition bug surfaced by absent persistence rather than masked by adoption.
+
+**A service-level `append(id, events)` beside handles.** Rejected: an id-addressed write path bypasses ownership; every write flows through the owning handle so the future lease check has exactly one door.
+
+**Persistence-owned batching configuration.** Rejected: the batching window is internal write-path scheduling, not a deployment-varying choice, so it is a provider constant and no configuration knob exists anywhere.
+
+## 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.
+
+## Related
+
+- [Session persistence as an abstract service](2026-06-14-session-persistence.md) — the seam this reshapes; its interface list reflects the handle API.
+- [Persistence export() and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the blank probe this note's metadata restores.
+- [Retain ignorable external session events](2026-08-30-retain-ignorable-external-session-events.md) — the read-side refusal contract, now shared through `storage-contract` helpers.
+- [Bounded session-persistence write batching](2026-08-08-bounded-session-persistence-write-batching.md) — the batching semantics the routed write path preserves as internal scheduling policy.

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

@@ -0,0 +1,42 @@
+# Agent Note: 基于句柄的会话持久化
+
+Status: implemented
+
+[English](2026-08-27-handle-based-session-persistence.md) | 中文
+
+## 问题
+
+先前的持久化 seam 承担的远不止存储。一个共享协调器订阅 `session/created`/`session/event`/`session/flush`/`session/disposed`,并接管任何已发布的会话(无主认领、HMR 重新播种、已存储前缀接管);一个带独占预留的有界已准备 Session LRU 用同一个缓存服务恢复与只读观察;提交式崩溃修复内嵌在 `load`/`prepare` 中;乐观的 revision 读取/复核/再读取循环充当所有权的替身,因此持续的外部写入方可能使读取活锁,而且没有任何机制排除第二个写入方。服务表面(十二个方法)把存储与 Session 构造和生命周期混在一起。跨进程写所有权——下一步——在那种形态里没有诚实的归宿:所有权属于一条有明确持有者的逐会话通道,而不属于一个全局监听器。
+
+## 决策
+
+**该 seam 是五个返回或供给逐会话句柄的服务方法。**`create(header)` 存储一个新会话并返回其持有的写句柄;`open(id, 'read' | 'write')` 打开一个已有会话;`stat(id)`/`list()` 观察快照(`header`、不透明 `revision`、可选的 `eventCount`/`sizeBytes` 提示——JSONL 后端提供 `sizeBytes`),而不读取日志;服务级 `flush()` 是一道后端范围的持久性屏障,排空并 flush 每一个活跃写句柄,逐会话聚合失败而不中途放弃清扫。该 seam 不承载原始工件导出:WebUI 的 ZIP 下载在 dsh-session-log-export 中从读句柄序列化逻辑日志(header 行 + 事件),因此每个后端的导出完全一致,仅 JSONL 可用的 501 路径也随之消失。`SessionHandle` 承载 `read(offset?, length?)`(经过验证的连续前缀切片,绝不返回撕裂尾部,逐句柄单调)、`append`(连续;完成时的持久化是尽力而为的,交付的 JSONL 后端恰好会立即持久化每个批次)、`flush`(持久性屏障,同时把空会话实体化)以及幂等且不可取消的 `close`。一种句柄类型同时服务两种访问——在读句柄上执行修改是运行时的 `SessionReadOnlyError`,这是本代码库其他 seam 的既定惯例,而非类型层面的拆分。单写者所有权由注册表在进程内强制(`SessionAlreadyOwnedError`);持久的跨进程租约是计划在同一形态上叠加的下一层。
+
+**agent 生命周期负责获取句柄;后端负责事件驱动的流程。**agent-loop——会话在生产环境中唯一的发布点——在发布之前获取句柄(新建会话用 `create`,并通过它追加构造 seed;恢复用 `open(id, 'write')`),并在与排空循环相同的记忆化 teardown 中关闭它。由于持久化已保证每个会话 id 只有一个活跃写句柄,后端一次性安装会话监听器并按 id 路由:`session/event` 进入持有句柄的有界 write-behind 窗口(内部调度策略,而非配置),`session/flush` 作为持久性与错误观察屏障,`session/disposed` 作为最终排空并关闭。写路径没有任何部分跨越包边界——没有写入器组件,没有批处理配置,没有排空注册表。根 fiber 的 dispose 会并发运行每个 fiber 的 disposer,因此 `close()` 本身会经由仍然打开的存储排空已路由的缓冲;后端 teardown 的关闭清扫使应用关闭无论哪个 fiber 先解退都不丢数据。该排空只保证已发出并缓冲的事件;turn 中途的根 dispose 仍会按设计丢失该 turn 尚未发出的剩余部分——下一次恢复的 `interruptedTurnClosers` 会持久地修复这段尾部。在生命周期之外发布的会话不再隐式持久化;生产环境中没有任何地方那样做。
+
+**语义崩溃修复移出了持久化。**恢复通过其写句柄读取物理上有效的日志,计算 `interruptedTurnClosers`,并把它们(连同构造器的 `session/end-seed` 标记)作为普通批次通过同一句柄追加——修复不是特殊的存储入口。只读观察方(session-query)仅在内存中配平被中断的冷日志,并拥有以 `stat().revision` 为键的冷 Session 缓存;持久化侧的已准备缓存与 revision 收敛循环被删除。
+
+**可见性与新鲜度是显式的。**已创建的会话自 `create` 起即可在进程内被观察到;物理实体化(纯粹的优化)可以推迟到第一次 append 或 flush,其他进程只能看到已实体化的会话,实体化之前崩溃意味着该会话从未存在。一旦某次 append 或 flush 完成,其后在同一后端实例上开始的读取至少能观察到该前缀——这正是 `message-feedback` 持久目标检查所依赖的保证。
+
+**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 `fs.stat` 派生尽力而为的令牌与 `sizeBytes`;存储介质能够廉价统计事件数的后端可以改为提供 `eventCount` 提示。会话列表的冷空白探测回归到这些元数据之上(`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),恢复了随路径查询一起移除的能力。
+
+## 考虑过的替代方案
+
+**类型化的读/写句柄类(或重载)。**不作为该 seam 的风格采纳:本代码库的 seam 偏好带访问标记的单一类型加运行时拒绝,而拆分会为一个编译期检查让每个面向消费方的类型翻倍。
+
+**在句柄旁保留协调器的接管/HMR 写路径。**不采纳:接管的存在是为了事后猜测所有权;当生命周期显式移交句柄后,无法服务旧句柄的重载后端会向写入器响亮地失败,而不是静默地重新认领日志,而没有句柄的会话是一个由持久化缺席暴露、而非被接管掩盖的组合缺陷。
+
+**在句柄旁提供服务级 `append(id, events)`。**不采纳:按 id 寻址的写路径绕过所有权;每次写入都流经持有句柄,使未来的租约检查恰好只有一扇门。
+
+**由持久化持有批处理配置。**不采纳:批处理窗口是写路径内部的调度策略,而非随部署变化的选择,因此它是 provider 常量,任何地方都不存在配置旋钮。
+
+## 后果
+
+恢复、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。
+
+## 相关
+
+- [作为抽象服务的会话持久化](2026-06-14-session-persistence.zh.md)——本 Note 重塑的 seam;其接口列表已反映句柄 API。
+- [持久化 export() 与预发布读取路径精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)——预备性的移除,包括本 Note 的元数据所恢复的空白探测。
+- [保留可忽略的外部会话事件](2026-08-30-retain-ignorable-external-session-events.zh.md)——读取侧的拒绝约定,现经由 `storage-contract` 辅助函数共享。
+- [为会话持久化写入批处理设定上界](2026-08-08-bounded-session-persistence-write-batching.zh.md)——被路由写路径作为内部调度策略保留的批处理语义。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-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/architecture/2026-08-30-retain-ignorable-external-session-events.md
-2026-08-30-retain-ignorable-external-session-events.md: c796a8dab1a9d127473a341fc98bc9934429fbdf
-2026-08-30-retain-ignorable-external-session-events.zh.md: 0c635b1082a31a0a35d01669ff9f933a1f218bee
+2026-08-30-retain-ignorable-external-session-events.md: e79713cf16c627706939f33f6748eaa4821de870
+2026-08-30-retain-ignorable-external-session-events.zh.md: 8fddbfdd7e60716b0a78ef6b33de09935e9bd4d7

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md

@@ -12,7 +12,7 @@ That producer inventory did not cover a third-party plugin that currently depend
 
 ## Decision
 
-The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read.
+The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. The persistence seam's stored-event validation (`validateStoredEvents`) continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read.
 
 The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决定
 
-标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。
+标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。持久化 seam 的已存事件校验(`validateStoredEvents`)继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。
 
 只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md
+2026-09-01-streamed-tool-call-identity.md: c52f39b735199270d65ed30a388333a217003237
+2026-09-01-streamed-tool-call-identity.zh.md: 9a7ffb6343870a08e06507408e007ad94fbad178

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md

@@ -0,0 +1,33 @@
+# Agent Note: Streamed tool-call identity survives empty continuation deltas
+
+Status: implemented
+
+English | [中文](2026-09-01-streamed-tool-call-identity.zh.md)
+
+## Problem
+
+The DeepSeek SSE translator assigned `id` and `name` on every tool-call delta that carried the field, so a continuation delta repeating either as an empty string erased the identity established by the call's first delta. The assembled block reached the loop with an empty name, which the tool registry refuses as `unknown tool ""`, leaving the affected models unable to run any tool. Gateways that fill those fields with `null` erased the identity the same way, and `WireToolCallDelta` declared both as `string | undefined`, keeping the observed `null` out of the compiler's reach.
+
+The empty identity outlived the turn. `appendToolCall` and `appendToolResult` write the block's id verbatim and no write path validates it, while `adoptSessionEvent` refuses a `tool/result` whose `callId` is empty, so the persistence coordinator wrapped that refusal in `SessionPersistenceCorruptionError`. A session that recorded one such call was writable and no longer loadable.
+
+## Decision
+
+`acceptIdentity` accepts only a non-empty string for a tool call's `id` and `name`; `undefined`, `null`, `''`, and any non-string leave the established value in place. The assignment set only narrows, so no input reaches a worse outcome than before. `WireToolCallDelta` widens `id`, `function.name`, and `function.arguments` to admit `null`, putting the values gateways actually send into the type system and making the runtime guard load-bearing rather than speculative.
+
+## Alternatives considered
+
+**Concatenate `id` and `name` across deltas.** Rejected: they are identity, not accumulation. Concatenation produces `Globnull` against a gateway that sends `null`, and a doubled name against one that repeats a non-empty value.
+
+**Refuse a conflicting non-empty identity mid-stream.** Deferred: a gateway that fragments a long tool name would be refused for it, and no observed provider re-sends a different non-empty identity within one call index.
+
+**Refuse a response whose tool call never receives an identity.** Deferred. It requires a new failure code, a change to the default retryable set, and a `[DONE]` gate that must not override the finish reason a provider already sent — cost and risk that the reported defect does not carry. The lenient wire it guards against is hypothetical: no report describes a stream that omits identity entirely.
+
+**Relax the session reader's empty-`callId` refusal.** Rejected: an empty `callId` cannot be paired back to the provider on the next request, so accepting it moves the failure into the model request. That refusal is the durable-boundary gate; the producer was the defect.
+
+## Consequences
+
+A continuation delta repeating identity empty or null is inert, so a call keeps the identity its first delta established, and the reported path to `unknown tool ""` and an unreadable session is closed. A stream that never carries identity at all still assembles an empty one, exactly as before; that path and the recovery of sessions already holding an empty `callId` are outside this change.
+
+## Testing
+
+`translate.spec.ts` covers empty and null continuation deltas, a repeated identical identity, and parallel calls holding separate identities under empty continuations. The existing cases for a wire that omits identity entirely keep their recorded empty-identity output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md

@@ -0,0 +1,33 @@
+# Agent Note:流式工具调用身份不被空续传分片抹除
+
+Status: implemented
+
+[English](2026-09-01-streamed-tool-call-identity.md) | 中文
+
+## 问题
+
+DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋值 `id` 与 `name`,因此续传分片把其中任一字段重复发送为空串时,会抹掉该调用首个分片已建立的身份。组装出的块带着空名字进入循环,工具注册表以 `unknown tool ""` 拒绝它,受影响的模型上任何工具都跑不起来。把这些字段填成 `null` 的网关会造成同样的抹除,而 `WireToolCallDelta` 把两者都声明为 `string | undefined`,让实际观察到的 `null` 落在编译器视野之外。
+
+空身份还会活过本轮。`appendToolCall` 与 `appendToolResult` 原样写入块的 id 且没有任何写入路径校验它,而 `adoptSessionEvent` 拒绝 `callId` 为空的 `tool/result`,持久化协调器于是把该拒绝包装成 `SessionPersistenceCorruptionError`。记录过一次这种调用的会话可写但不再可读。
+
+## 决定
+
+`acceptIdentity` 对工具调用的 `id` 与 `name` 只接受非空字符串;`undefined`、`null`、`''` 以及任何非字符串都保留已建立的值。会触发赋值的输入集合只减不增,因此没有任何输入会比改动前更差。`WireToolCallDelta` 把 `id`、`function.name` 与 `function.arguments` 放宽到允许 `null`,使网关实际发送的值进入类型系统,运行时守卫因此是承重的而非臆测的。
+
+## 考虑过的替代方案
+
+**跨分片拼接 `id` 与 `name`。** 否决:它们是身份而非累积。面对发送 `null` 的网关,拼接产生 `Globnull`;面对重复发送非空值的网关,产生重复的名字。
+
+**流中途拒绝冲突的非空身份。** 推迟:分片发送长工具名的网关会因此被拒,且没有观察到任何提供方在同一个调用 index 内改发不同的非空身份。
+
+**拒绝始终未获得身份的响应。** 推迟。它需要新增失败 code、改动默认可重试集,还需要一个不得覆盖提供方已给出终止原因的 `[DONE]` 闸门——这些代价与风险,已报告的缺陷并不需要承担。它所防的宽松线上格式是假想的:没有任何报告描述过完全不发送身份的流。
+
+**放宽会话读取端对空 `callId` 的拒绝。** 否决:空 `callId` 无法在下一次请求中与提供方配对,接受它只是把失败推进模型请求。该拒绝是持久化边界的闸门;缺陷在生产方。
+
+## 后果
+
+重复发送空或 null 身份的续传分片不产生作用,调用因此保有其首个分片建立的身份,通往 `unknown tool ""` 与不可读会话的已报告路径就此切断。完全不携带身份的流仍会组装出空身份,与改动前一致;该路径以及已经写入空 `callId` 的会话恢复都不在本次改动范围内。
+
+## 测试
+
+`translate.spec.ts` 覆盖空与 null 续传分片、重复的相同身份,以及空续传下并行调用各自保有身份。原有那些描述完全不发送身份的线上格式的用例,保留其记录的空身份输出。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.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-projcache-cross-version-read-compat.md
-2026-09-02-projcache-cross-version-read-compat.md: 64b52ba482e5850e1d0c3d473ca87ba7822af67f
-2026-09-02-projcache-cross-version-read-compat.zh.md: c8adad9fd2b7184418a4da44e8b2e5d1f31feed7
+2026-09-02-projcache-cross-version-read-compat.md: 1d157d90b04fe92e86d858a8e5424a5e517721f6
+2026-09-02-projcache-cross-version-read-compat.zh.md: 3948126271a84810abf4fe1c1e6e874b279a4f5e

+ 13 - 8
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md

@@ -1,4 +1,4 @@
-# Agent Note: Projection-cache cross-version read compatibility (session_projcache v3/v4/v5)
+# Agent Note: Projection-cache cross-version read compatibility (session_projcache v3/v4/v5 → v6)
 
 Status: implemented
 
@@ -32,10 +32,14 @@ Declared read compatibility — reads tolerate vouched-for older versions, write
 1. **`DomainSpec.compatibleVersions` (new, optional)**: the domain owner declares "records stored under these older versions are also readable under the current record schemas" (typically by declaring the fields old records lack as optional). `defineDomain` validates each entry as a non-negative integer below the current version; `descriptorOf` projects the set onto the backend `KvUnitDescriptor`.
 2. **json backend per-record reads** accept version stamps in "current ∪ compatibleVersions"; anything outside the set is still discarded as foreign. **The write path always stamps the current version** (the first checkpoint after reading an old record naturally advances it). The `single` layout stays exact-version.
 3. **Legacy-bootstrap version gate (the actual bug fix)**: the old whole-unit file's `unit.version` must fall inside the accepted set to be migrated; otherwise the file is left alone and the unit reads empty — stamping records the owner never vouched for turns a discardable stale cache into hard schema failures at the domain layer.
-4. **The projcache domain declares `version: 5, compatibleVersions: [3, 4]`**, and the two lineage fields become `.optional()`. The single reader of stored identities, `identityMatches`, normalizes absence to the unseeded lineage (`?? false` / `?? 0`): exact for unforked sessions, while a forked session's expectation is seeded → natural mismatch → discard and cold rebuild, so the lineage binding loses none of its protection.
-5. **The poisoned state self-heals**: documents stamped 5 without lineage fields parse under the optional schema (their content is the real pre-upgrade cache data), so the home boots again and titles serve immediately.
+4. **The projcache domain declares `version: 6, compatibleVersions: [3, 4, 5]`**, and the two lineage fields become `.optional()`. The single reader of stored identities, `identityMatches`, normalizes absence to the unseeded lineage (`?? false` / `?? 0`): exact for unforked sessions, while a forked session's expectation is seeded → natural mismatch → discard and cold rebuild, so the lineage binding loses none of its protection.
+5. **The poisoned state self-heals**: documents stamped 5 without lineage fields are declared compatible and parse under the optional schema (their content is the real pre-upgrade cache data), so the home boots again and titles serve immediately.
 6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `<key>.json.bak.<YYYYMMDDHHmm>`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage.
 
+### v5 → v6 compatibility
+
+Version 6 changes only the current write stamp and keeps the v5 record schema. `compatibleVersions: [3, 4, 5]` therefore admits both healthy v5 records and v5-stamped lineage-less records produced by the faulty bootstrap. The current schema accepts absent lineage; `identityMatches` interprets it as unseeded and rejects the record for a seeded session. The next successful checkpoint rewrites an accepted v5 record with a v6 stamp and complete lineage. No separate v5→v6 rewrite runs at startup: an unaccepted version reads as absent, while a schema-invalid accepted record follows `backup-and-skip`.
+
 ### Upgrade matrix
 
 | home shape | behavior after the fix |
@@ -43,19 +47,20 @@ Declared read compatibility — reads tolerate vouched-for older versions, write
 | v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → titles serve immediately |
 | v3 + poisoned new tree | new-tree documents read directly (optional tolerance) → boot restored, titles serve immediately |
 | v4 per-record | documents read directly (4 ∈ accepted set) → titles serve immediately |
-| v5 healthy | unaffected |
+| v5 healthy | documents read directly (5 ∈ accepted set) → titles serve immediately |
+| v6 current | unaffected |
 | old records of forked (seeded) sessions | identity mismatch → discarded, cold rebuild when the session opens (safe side) |
 
 ## Alternatives considered
 
-- **Discard-and-rebuild only** (bootstrap gate + bump to v6): fixes the boot, but every SessionList title is lost after the upgrade until each session is reopened — fails the upgrade-and-go product requirement.
+- **Discard-and-rebuild only** (bootstrap gate without compatible versions): fixes the boot, but every SessionList title is lost after the upgrade until each session is reopened — fails the upgrade-and-go product requirement.
 - **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02).
-- **Roll the domain version back 5→4**: the smallest diff (three lines), but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache.
+- **Roll the domain version back to 4**: a small diff, but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache.
 
 ## Consequences
 
 - A deployment routing this domain to the sqlite backend gets none of the tolerance: sqlite implements neither `compatibleVersions` nor `backupRecord`, so behavior degrades to the old strict-version semantics (a whole-unit version mismatch still refuses with `version-mismatch`; nothing loosens, nothing serves wrong values). Shipped compositions route this domain to json, so this stays a deployment-configuration risk only.
-- The optional lineage fields widen what a current-version document may omit: a v5-stamped record stripped of its lineage decodes as unseeded. The identity match still refuses it for seeded callers, and the per-row `ver` guard still screens every value, so the residual exposure is an unseeded caller reading an unseeded-shaped record — the same trust extended to genuine pre-lineage records.
+- The optional lineage fields let accepted records omit lineage: a lineage-less record decodes as unseeded. The identity match still refuses it for seeded callers, and the per-row `ver` guard still screens every value, so the residual exposure is an unseeded caller reading an unseeded-shaped record — the same trust extended to genuine pre-lineage records.
 - `backupRecord` overwrites a same-minute backup of the same key (the newer bytes win); distinct minutes and distinct keys never collide.
 
 ## Testing
@@ -63,7 +68,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write
 - `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard.
 - `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`.
 - `session-projection-cache` unit tests: records without lineage fields serve unseeded sessions verbatim and are discarded for seeded ones.
-- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (current), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned shape, synthesized from the v3 record) — each opened through the real storage stack, asserting the listing serves the archived title and that a live write rewrites the document to the current version (v5 stamp + lineage fields + fresh value); plus the backup-and-skip behavior for a schema-failing record (boot survives, `.bak` lands, log is concrete, neighbor records unharmed).
+- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (0.1.2-alpha.4), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned shape, synthesized from the v3 record) — each opened through the real storage stack, asserting the listing serves the archived title and that a live write rewrites the document to the current version (v6 stamp + lineage fields + fresh value); plus the backup-and-skip behavior for a schema-failing record (boot survives, `.bak` lands, log is concrete, neighbor records unharmed).
 - End-to-end acceptance, executed against the real release artifacts: the published 0.1.1-rc.2 and 0.1.2-alpha.3 npm builds seeded homes through their own web apps (model turns plus a rename RPC), the published 0.1.2-alpha.4 build reproduced both failures (including the poisoned tree), and the fixed build served every home shape — pristine v3, poisoned v3, v4, and fresh — with the SessionList RPC returning the recorded titles verbatim.
 
 Future bump procedure: when a new version's shape can tolerate old records through "optional fields + reader normalization", add the old version to `compatibleVersions`; otherwise bump normally (discard and rebuild) and remove the no-longer-compatible versions from the set. Either way, the package README requires the bump to land with archived fixtures and tests proving the chosen disposition.

+ 13 - 8
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 投影缓存跨版本读兼容(session_projcache v3/v4/v5)
+# Agent Note: 投影缓存跨版本读兼容(session_projcache v3/v4/v5 → v6
 
 Status: implemented
 
@@ -32,10 +32,14 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `
 1. **`DomainSpec.compatibleVersions`(新增,可选)**:域 owner 声明"这些旧版本的存量记录在当前记录 schema 下也可读"(典型手段:新增字段标 optional)。`defineDomain` 校验各项为小于当前 version 的非负整数;`descriptorOf` 透传到后端 `KvUnitDescriptor`。
 2. **json 后端 per-record 读**:接受"当前版本 ∪ compatibleVersions"内的版本戳,集合外照旧视为 foreign 丢弃;**写路径永远戳当前版本**(读到旧记录后的下一次 checkpoint 自然把它推进到当前版本)。single 布局维持 exact-version 不变。
 3. **legacy bootstrap 版本把关(bug 修复本体)**:旧单文件的 `unit.version` 必须落在接受集合内才迁移,否则视为空 unit 留在原地——为 owner 未背书的记录打当前版本戳,会把"可丢弃的过期缓存"变成 domain 层的 schema 硬失败。
-4. **projcache 域声明 `version: 5, compatibleVersions: [3, 4]`**;两个 lineage 字段改为 `.optional()`。唯一消费 stored identity 的读点 `identityMatches` 把缺失归一化为 unseeded lineage(`?? false` / `?? 0`):对非 fork 会话这是精确值;fork 会话的 expected 是 seeded → 天然 mismatch → 丢弃冷读重建,lineage 绑定的防护不放松。
-5. **投毒态自愈**:v5 戳缺 lineage 字段的文档被 optional schema 直接接受(内容本就是升级前的真实缓存数据),home 恢复可启动且标题立即可服务。
+4. **projcache 域声明 `version: 6, compatibleVersions: [3, 4, 5]`**;两个 lineage 字段改为 `.optional()`。唯一消费 stored identity 的读点 `identityMatches` 把缺失归一化为 unseeded lineage(`?? false` / `?? 0`):对非 fork 会话这是精确值;fork 会话的 expected 是 seeded → 天然 mismatch → 丢弃冷读重建,lineage 绑定的防护不放松。
+5. **投毒态自愈**:v5 戳缺 lineage 字段的文档被声明为兼容,并由 optional schema 接受(内容本就是升级前的真实缓存数据),home 恢复可启动且标题立即可服务。
 6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `<key>.json.bak.<YYYYMMDDHHmm>`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。
 
+### v5 → v6 兼容方式
+
+版本 6 只改变当前写入的版本戳,沿用 v5 记录 schema。`compatibleVersions: [3, 4, 5]` 因此同时接受健康的 v5 记录,以及错误 bootstrap 生成的 v5 戳、缺 lineage 记录。当前 schema 允许 lineage 缺失;`identityMatches` 将其解释为 unseeded,并对 seeded 会话拒绝该记录。下一次成功的 checkpoint 会用 v6 戳和完整 lineage 重写已接受的 v5 记录。启动时不单独运行 v5→v6 重写:未接受的版本读作不存在,schema 校验失败的已接受记录则执行 `backup-and-skip`。
+
 ### 升级矩阵
 
 | home 形态 | 修复后行为 |
@@ -43,19 +47,20 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `
 | v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 标题立即可服务 |
 | v3 + 投毒新树 | 新树文档直接读入(optional 容忍)→ 启动恢复、标题立即可服务 |
 | v4 per-record | 文档直接读入(4 ∈ 接受集)→ 标题立即可服务 |
-| v5 正常 | 不受影响 |
+| v5 正常 | 文档直接读入(5 ∈ 接受集)→ 标题立即可服务 |
+| v6 当前版本 | 不受影响 |
 | fork(seeded)会话的旧记录 | identity mismatch → 丢弃,打开会话时冷读重建(安全侧) |
 
 ## 备选方案
 
-- **只丢弃重建**(bootstrap 把关 + bump v6):启动可修,但升级后 SessionList 标题全丢、要逐会话打开才恢复——不满足升级即用的产品要求。
+- **只丢弃重建**(bootstrap 把关但不声明兼容版本):启动可修,但升级后 SessionList 标题全丢、要逐会话打开才恢复——不满足升级即用的产品要求。
 - **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。
-- **域版本回退 5→4**:改动最小(三行),但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。
+- **域版本回退到 4**:改动很小,但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。
 
 ## 影响
 
 - 部署方若把本域路由到 sqlite 后端,得不到任何容忍能力:sqlite 既未实现 `compatibleVersions` 也没有 `backupRecord`,行为退化为原有的严格版本语义(整 unit 版本不匹配仍 `version-mismatch` 拒开;不放松、不出错值)。shipped 组合固定路由 json,此风险仅存在于部署配置层面。
-- optional lineage 字段放宽了当前版本文档可缺省的范围:被剥离 lineage 的 v5 戳记录会解码为 unseeded。身份比对仍会对 seeded 调用方拒收,逐行 `ver` 守卫仍筛查每个值,残余暴露面只是 unseeded 调用方读到 unseeded 形态的记录——与真实 pre-lineage 记录享有的信任完全相同。
+- optional lineage 字段允许被接受的记录缺少 lineage:无 lineage 的记录会解码为 unseeded。身份比对仍会对 seeded 调用方拒收,逐行 `ver` 守卫仍筛查每个值,残余暴露面只是 unseeded 调用方读到 unseeded 形态的记录——与真实 pre-lineage 记录享有的信任完全相同。
 - `backupRecord` 对同一键的同一分钟内重复备份会覆盖前一份(新字节胜出);不同分钟、不同键永不冲突。
 
 ## 测试
@@ -63,7 +68,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `
 - `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。
 - `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。
 - `session-projection-cache` 单测:缺 lineage 字段的记录对 unseeded 会话按原值服务、对 seeded 会话丢弃。
-- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(当前版)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域,断言列表读出归档标题、且 live 写把文档重写为当前版本(v5 戳 + lineage 字段 + 新值);外加 schema 失败记录的 backup-and-skip 行为(启动不失败、`.bak` 落盘、日志具体、邻居记录不受累)。
+- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(0.1.2-alpha.4)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域,断言列表读出归档标题、且 live 写把文档重写为当前版本(v6 戳 + lineage 字段 + 新值);外加 schema 失败记录的 backup-and-skip 行为(启动不失败、`.bak` 落盘、日志具体、邻居记录不受累)。
 - 端到端验收,以真实发布物执行:已发布的 0.1.1-rc.2 与 0.1.2-alpha.3 npm 包经各自 web app 造数(真实模型对话 + rename RPC),已发布的 0.1.2-alpha.4 包复现两类故障(含投毒树),修复后构建对纯净 v3、投毒 v3、v4、全新四种 home 形态经 SessionList RPC 原样返回记录在案的标题。
 
 未来 bump 流程:新版本结构若可用"optional 字段 + 读点归一化"容忍旧记录,就把旧版本加入 `compatibleVersions`;否则正常 bump(丢弃重建),并把不再兼容的版本从集合中移除。无论哪条路,包 README 都要求 bump 随附归档 fixture 和论证所选处置方式的测试。

+ 0 - 38
.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md

@@ -1,38 +0,0 @@
-# Agent Note: Load sessions persisted before message identity
-
-Status: implemented
-
-English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md)
-
-## Problem
-
-The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL Sessions still held the immediately preceding forms: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-form validation rejected them before resume could construct a live `Session`.
-
-Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party provider without weakening validation for unrelated obsolete or malformed events.
-
-## Decision
-
-`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message:<session-id>:<event-seq>` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant.
-
-The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths.
-
-The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction.
-
-## Alternatives considered
-
-**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation.
-
-**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require an atomic replacement mechanism, and expand a read compatibility fix into a migration system.
-
-**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends.
-
-## Consequences
-
-Pre-identity JSONL Sessions resume with their original message content, sources, assistant provider/model fields, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen.
-
-This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference and JSONL provider, including deterministic reload and tool-result replacement identity.
-
-## Related
-
-- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract.
-- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary.

+ 0 - 38
.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md

@@ -1,38 +0,0 @@
-# Agent Note: 加载消息标识机制引入前持久化的会话
-
-Status: implemented
-
-[English](2026-07-28-load-pre-identity-session-messages.md) | 中文
-
-## 问题
-
-带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有 v0 JSONL Session 仍保留紧邻该变更之前的表示:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些 Session 的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前表示验证会拒绝它们,导致恢复流程无法构造 live `Session`。
-
-消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的 first-party provider 所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。
-
-## 决策
-
-`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入用 `MessageId`:`legacy-message:<session-id>:<event-seq>`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。
-
-同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其活跃会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将活跃会话的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。
-
-这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。
-
-## 考虑过的替代方案
-
-**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。
-
-**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储约定,还需要原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
-
-**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。
-
-## 后果
-
-消息标识机制引入前的 JSONL Session 可以恢复,并保留原始消息内容、来源、assistant 的 provider/model 字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
-
-这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器约定会通过内存参考实现与 JSONL provider 验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
-
-## 相关
-
-- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.zh.md):该记录负责当前的消息标识与不可变性约定。
-- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.zh.md):该记录负责仅追加后端与恢复边界。

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

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

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

@@ -26,7 +26,7 @@ No session-query or session-persistence surface changed. The shipped TUI composi
 
 **Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications.
 
-**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times.
+**Surface a last-modified time through `list()`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from the stored log's file metadata. Reintroduce if a second consumer needs metadata activity times.
 
 **A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache.
 

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

@@ -26,7 +26,7 @@ session-query 与 session-persistence 的任何接口都未改变。随附的 TU
 
 **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。
 
-**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。
+**通过 `list()`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能从已存日志的文件元数据得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。
 
 **专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效约定(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
 

+ 0 - 40
.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md

@@ -1,40 +0,0 @@
-# Agent Note: Load sessions from the pre-react-loop format
-
-Status: implemented
-
-English | [中文](2026-08-04-load-pre-react-loop-sessions.zh.md)
-
-## Problem
-
-The react-loop simplification changed durable events while retaining `SESSION_FORMAT_VERSION` 0. Stored sessions from the change's base contain `steering/message` and `turn/start.trigger`; their terminal reasons also use coarse `aborted`, separate `disposed`, and two older error payloads. Current surface and turn invariants cannot replay those records directly.
-
-The new durable inbox is not part of this compatibility problem. The base emitted process-local inbox notifications but no `agent/inbox/*` session events, so replaying old history as pending work would resurrect already claimed or discarded prompts.
-
-## Decision
-
-`PersistenceCoordinator` recognizes the exact pre-react-loop shapes after backend decoding and projects them into the current read view. It removes the obsolete `turn/start.trigger`, converts `steering/message` to the same identified `user/message`, maps old failure facts into the current structured error, folds `disposed` into an aborted turn with the `disposed` cause, and represents coarse aborted records with the persistence-only `{ kind: 'legacy' }` cause because their caller is unavailable.
-
-The coordinator applies the projection to `load`, `inspect`, adoption, HMR prefix comparison, and `readFrom`. A seek-capable `readFrom` normally reads only its suffix; when that suffix contains a legacy event needing an earlier replacement identity, the coordinator loads and normalizes the complete prefix before returning the requested seq range.
-
-The importer does not synthesize inbox splices. A resumed pre-react-loop agent begins with empty pending lists, matching the base runtime's inability to persist pending inbox work. The stored artifact remains append-only and later events use the current format.
-
-## Alternatives considered
-
-**Treat the same-version records as unsupported.** This follows the pre-release default but strands sessions produced by the PR base even though the removed steering content and terminal facts have complete mappings.
-
-**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work.
-
-**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would invent a caller that the old record did not name. A dedicated `legacy` cause keeps the stop classification without making a false audit claim.
-
-**Rewrite stored JSONL records.** A rewrite would violate the append-only contract and require atomic migration machinery for a read compatibility boundary.
-
-## Consequences
-
-Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory and JSONL `load`/`inspect`/`readFrom`; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty.
-
-This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity.
-
-## Related
-
-- [Load sessions persisted before message identity](2026-07-28-load-pre-identity-session-messages.md) — owns deterministic identities and the general read-only import boundary for another same-version format change.
-- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns append-only backend storage and resume.

+ 0 - 40
.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md

@@ -1,40 +0,0 @@
-# Agent Note: 加载 react-loop 重构前格式的会话
-
-Status: implemented
-
-[English](2026-08-04-load-pre-react-loop-sessions.md) | 中文
-
-## 问题
-
-react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持久事件。该变更基线所存储的会话包含 steering(中途引导)事件 `steering/message`,以及 `turn/start.trigger` 字段;其终止原因还使用粗粒度 `aborted`、独立的 `disposed` 和两种旧版错误载荷。当前表层和轮次不变量无法直接回放这些记录。
-
-新的持久 inbox 不属于此兼容性问题。该基线会发出进程本地 inbox 通知,但不会产生 `agent/inbox/*` 会话事件,因此将旧历史回放为待处理工作会让已经领取或丢弃的提示词再次执行。
-
-## 决策
-
-`PersistenceCoordinator` 会在后端解码后识别 react-loop 重构前的确切形状,并将其投影为当前读取视图。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。
-
-协调器会把该投影应用于 `load`、`inspect`、接管、HMR(热模块替换)前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。
-
-导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent(智能体)从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。
-
-## 考虑过的替代方案
-
-**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR(Pull Request)基线产生的会话无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。
-
-**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。
-
-**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会凭空指定旧记录未注明的调用方。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。
-
-**重写已存储的 JSONL 记录。** 重写会违反仅追加约定,并要求为读取兼容边界建立原子迁移机制。
-
-## 后果
-
-以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器约定覆盖内存与 JSONL 的 `load`/`inspect`/`readFrom`;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。
-
-此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。
-
-## 相关资料
-
-- [加载消息标识机制引入前持久化的会话](2026-07-28-load-pre-identity-session-messages.zh.md):负责另一项同版本格式变更的确定性标识和通用只读导入边界。
-- [以抽象服务实现会话持久化](../architecture/2026-06-14-session-persistence.zh.md):负责仅追加后端存储和恢复。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md
-2026-08-13-bounded-cold-blank-verification.md: ab2f3b5a0534a98e02a3e2494ca2fff1223efe81
-2026-08-13-bounded-cold-blank-verification.zh.md: 5dc4b62f7ac43ebd3c4a8cef58f5da9af367520a
+2026-08-13-bounded-cold-blank-verification.md: 8244b93641cfe576cd2f5b0c618ae69fbb215dd5
+2026-08-13-bounded-cold-blank-verification.zh.md: 2c7f392fa8fbd3fed0641fe2b95e717f76cd78fb

+ 5 - 7
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md

@@ -14,15 +14,15 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess
 
 `dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`.
 
-A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible.
+A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank and are served `blank: false`, keeping the Session visible. The earlier physical-size probe — a `locate()` path plus a `coldBlankProbeMaxBytes` eligibility threshold gating an exact `readFrom(id, 0)` fold — is removed with the seam's path query ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)); persistence snapshot metadata (`eventCount`/`sizeBytes` on `stat()`/`list()`) is the reintroduction path for exact cold verification.
 
-`updatedAt` is the later of `createdAt` and `lastPromptAt`. An eligible artifact read supplies exact `lastPromptAt` at no additional I/O cost; other cache misses or stale checkpoints order the Session too old rather than promoting it from an unrelated file write. After each asynchronous cold read, the gateway checks the live store again and replaces the cold result with an attached summary when another request resumed that Session meanwhile.
+`updatedAt` is the later of `createdAt` and `lastPromptAt`. A cache miss or stale checkpoint orders the Session too old rather than promoting it from an unrelated file write.
 
 ## Alternatives considered
 
 **Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session.
 
-**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size eligibility check targets small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. It intentionally does not add a persistence operation solely to make the threshold atomic with the read: concurrent growth may increase one probe's read cost, but the additional events can only preserve visibility or change a blank result to non-blank.
+**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes; unverified cold entries degrade toward visibility instead.
 
 **Store blankness and recency in an authoritative persistence index.** Deferred because the shipped JSONL provider has an immutable first line and would require a second durable artifact with ordered updates. An out-of-tree provider may use its own index only with defined update atomicity, versioning, and recovery. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md).
 
@@ -30,8 +30,6 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain
 
 ## Consequences
 
-Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose observed physical size is within the configured threshold when its cache does not already prove non-blank. The default threshold compares compressed bytes for the shipped Zstandard JSONL backend.
+A stale cache cannot hide a stored `turn/start`, and a cold list performs no artifact I/O: cold rows are served from cached projections only. Blank cold Sessions without a cached non-blank projection remain visible, and missing or delayed recency cache entries fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened.
 
-Blank artifacts above the threshold and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries for artifacts that are not read fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened.
-
-The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold eligibility, stale-true rejection, monotonic false reuse, exact small-log recency, live-attachment races, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it.
+The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins stale-true rejection, monotonic false reuse, cache-miss visibility, human-prompt recency, and fiber disposal.

+ 5 - 7
.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md

@@ -14,15 +14,15 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
 
 `dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。
 
-冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见
+冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空,因而按 `blank: false` 提供,让 Session 保持可见。早先的物理大小探测——`locate()` 路径加上门控一次精确 `readFrom(id, 0)` 折叠的 `coldBlankProbeMaxBytes` 资格阈值——随该 seam 的路径查询一并移除([导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md));persistence 快照元数据(`stat()`/`list()` 上的 `eventCount`/`sizeBytes`)是重新引入精确冷验证的路径
 
-`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。符合资格的工件读取无需额外 I/O 即可提供精确 `lastPromptAt`;其他 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。每次异步冷读取后,网关都会再次检查实时 store;若另一请求期间已恢复该 Session,则用已附加摘要替换冷结果。
+`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。
 
 ## Alternatives considered
 
 **信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。
 
-**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空
+**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长;未经核验的冷条目转而向保持可见降级
 
 **把空白状态与最近时间存入权威 persistence index。** 暂缓,因为交付的 JSONL provider 首行不可变,需要增加带有顺序写入要求的第二份持久工件。仓库外 provider 只有定义更新原子性、版本与恢复语义后才可使用自己的索引。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)负责。
 
@@ -30,8 +30,6 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N
 
 ## Consequences
 
-既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且观测物理大小在配置阈值内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该阈值比较压缩后的字节数
+陈旧 cache 无法隐藏已存的 `turn/start`,且冷列表不做任何工件 I/O:冷行只从缓存投影提供。没有缓存非空投影的空白冷 Session 保持可见,缺失或延迟的最近时间 cache 条目回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面
 
-超过阈值的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。对于未被读取的工件,缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。
-
-网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小资格、拒绝陈旧 true、复用单调 false、小日志精确最近时间、实时附加竞态、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。
+网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了拒绝陈旧 true、复用单调 false、cache miss 保持可见、真人 prompt 最近时间和 fiber 销毁。

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml → .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md
-2026-08-04-load-pre-react-loop-sessions.md: e95817ee60647ca002060a4f90c2263d4fe7ce42
-2026-08-04-load-pre-react-loop-sessions.zh.md: 98fb1f530f5168fc02b312775d1bb8e6d305b8f8
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md
+2026-09-01-host-goal-pause-aborts-turn.md: 8a6acc1403ea9d8f1dab241e7888bb98aadcde31
+2026-09-01-host-goal-pause-aborts-turn.zh.md: 60170cd1029ce210fa1002ab407b4083af36d72e

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md

@@ -0,0 +1,29 @@
+# Agent Note: Host-initiated goal pause aborts the live turn
+
+Status: implemented
+
+English | [中文](2026-09-01-host-goal-pause-aborts-turn.zh.md)
+
+## Problem
+
+Clicking "pause goal" in the Web UI moved the goal to `paused` and disarmed automatic continuation, but the model turn already running kept going. The model could keep acting and call `update_goal resume` inside that same turn, immediately undoing the pause, so a manual pause had no real control over goal execution.
+
+## Decision
+
+The goal round driver now reads the `change` on every `goal/changed` event. When `operation === 'pause'` and the pause was not initiated by the agent's own turn, the driver aborts the live turn with `agent.cancel({ kind: 'user' }, { keepInbox: true })`. The Web button runs outside any agent initiator boundary, while a model's `update_goal pause` runs with the agent as the current initiator; the driver distinguishes them with `ctx.agents.currentInitiator() !== agent`. The abort is intentionally broad — it stops any live turn, not just a goal round — because a manual pause is a strong "stop now" signal and disarming alone stops future rounds but not the execution already under way.
+
+`keepInbox` preserves pending work. A queued goal round already fails the existing pre-step reservation check once the goal is disarmed, so it cannot run after the pause.
+
+The idle handler that pauses a cancelled goal is fenced to the dropped attempt's exact `{ goalId, revision }`. A resume bumps the revision, so a pause followed by an immediate resume — before the aborted turn converges to idle — is preserved instead of being re-paused by the stale cancelled attempt.
+
+## Alternatives considered
+
+**Cancel on every pause, including the model's own.** Rejected: a model that pauses in response to a direct human request should finish its turn and report; aborting mid-tool-call cuts off that acknowledgment without adding control.
+
+**Put the cancellation in the goal service's `pause`.** Rejected: `pause` is one shared entry point for host and model callers, so the service would still need the same initiator test. Keeping control handling in the round driver leaves the goal service a durable state and event owner.
+
+**Scope the abort to a turn actually running a goal round.** Rejected: the live turn is the execution the user asked to stop, and the extra attempt-state check adds a subtle path without changing the outcome the issue asks for.
+
+## Consequences
+
+A Web "pause goal" now aborts the running turn, so the model cannot keep acting or resume the just-paused goal in that turn. A pause followed by an immediate resume keeps the resumed goal running. Model-initiated pauses are unchanged. The change is confined to the round driver and its tests; the goal domain, tool authority, and durable formats are unchanged.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 宿主发起的 goal 暂停中止当前轮次
+
+Status: implemented
+
+[English](2026-09-01-host-goal-pause-aborts-turn.md) | 中文
+
+## 问题
+
+在 Web UI 点击「暂停目标」会把 goal 改成 `paused` 并解除自动续跑的武装(disarmed),但已经在跑的模型轮次不会停止。模型还能继续行动,并在同一个轮次里调用 `update_goal resume`,立刻撤销这次暂停,因此人工暂停对 goal 执行没有真正的控制力。
+
+## 决策
+
+goal round driver 现在会读取每个 `goal/changed` 事件里的 `change`。当 `operation === 'pause'` 且暂停不是由 agent 自己的轮次发起时,driver 用 `agent.cancel({ kind: 'user' }, { keepInbox: true })` 中止当前轮次。Web 按钮运行在任何 agent initiator 边界之外,而模型调用 `update_goal pause` 时当前 initiator 就是该 agent;driver 用 `ctx.agents.currentInitiator() !== agent` 来区分两者。中止是有意放宽的——它会停掉任何正在运行的轮次,而不只是 goal round——因为人工暂停是强烈的「现在停止」信号,仅 disarmed 只能阻止后续轮次,停不掉正在进行的执行。
+
+`keepInbox` 会保留待处理工作。一旦 goal 被 disarmed,已排队的 goal round 就会在既有的 pre-step reservation 校验里失败,因此暂停后不会再运行。
+
+暂停被取消 goal 的 idle 处理器被栅栏限定到被丢弃 attempt 的精确 `{ goalId, revision }`。resume 会推进 revision,因此在被中止轮次收敛到 idle 之前「暂停后立即 resume」会被保留,而不会被过期的 cancelled attempt 再次暂停。
+
+## 考虑过的替代方案
+
+**对每次暂停都中止轮次,包括模型自己发起的。** 否决:响应人类直接请求而暂停的模型应当完成本轮并给出回复;在工具调用中途中止只会截断这层确认,却换不来更多控制力。
+
+**把中止逻辑放进 goal 服务的 `pause`。** 否决:`pause` 是宿主与模型共用的唯一入口,服务里同样需要这个 initiator 判断。把控制处理留在 round driver,可以让 goal 服务保持为持久状态与事件的拥有者。
+
+**把中止限定到真正在跑 goal round 的轮次。** 否决:正在运行的轮次正是用户要求停止的执行,额外的 attempt 状态检查只会增加一条微妙路径,却不改变本 issue 要求的结果。
+
+## 后果
+
+现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。暂停后立即 resume 会保留被恢复的 goal 继续运行。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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-10-agent-session-identity-and-log-location.md
-2026-07-10-agent-session-identity-and-log-location.md: 1bd16fa4123aa8a44719aa0e8c40c4e662f7cb3b
-2026-07-10-agent-session-identity-and-log-location.zh.md: 1b54949fb34a0593eaa255e8ff548c203c8023c8
+2026-07-10-agent-session-identity-and-log-location.md: c849ffb882a3334c380b6736a8c8fd8d07a9da40
+2026-07-10-agent-session-identity-and-log-location.zh.md: 07826993839257dd893ef2a56059170ef9d0db31

+ 12 - 34
.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md

@@ -1,4 +1,4 @@
-# Agent Note: Expose agent session identity and JSONL location to tools and hooks
+# Agent Note: Expose agent session identity to tools and hooks
 
 Status: implemented
 
@@ -6,29 +6,12 @@ English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md)
 
 ## Problem
 
-An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
+An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call. Resume, forks, and concurrent parent/child agents make any ambient guess unreliable, while future plugins may need to expose other harness-owned environment facts to shell commands.
 
 The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs.
 
 ## Decision
 
-Extend the [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query:
-
-```ts
-import type { SessionHeader } from '@deepseek-ai/dsh-session'
-
-interface SessionLocation {
-  readonly kind: string
-  readonly path: string
-}
-
-interface SessionPersistence {
-  locate(meta: SessionHeader): SessionLocation | undefined
-}
-```
-
-`path` is an absolute local path to the provider's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. An out-of-tree provider without an honest local per-Session artifact returns `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists.
-
 The model-facing bash package owns a `ctx.shellEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment API enumerable for diagnostics and future prompt/UI consumers.
 
 The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`:
@@ -36,15 +19,16 @@ The registry rebuilds a trusted overlay for every foreground and background bash
 - `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`.
 - `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness.
 - `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`.
-- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`.
 
-Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`.
+A transcript-location fact is deliberately absent. An earlier form of this decision also extended the persistence seam with a `locate()` path query feeding a `DSH_SESSION_JSONL` variable and the hook bridges' `transcript_path`; the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns removing that half — the paths were only readable with compression disabled, and the seam no longer exposes artifact locations.
+
+Plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`.
 
 The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `ShellExecRequest.dshEnv` / `ShellExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments.
 
 The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required.
 
-The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session.
+The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) keep `transcript_path` in their wire payloads for protocol shape but always send `''` (Claude Code) / `null` (Codex); the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns that degradation.
 
 ## Peer product findings
 
@@ -52,36 +36,30 @@ Peer products separate stable identity from physical storage. Codex injects stab
 
 ## Lifecycle and persistence semantics
 
-A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee.
-
-Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe.
+A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID`. Resume reuses the loaded header and therefore the same id. Fork and spawn create new session ids. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. The registry is effect-scoped and HMR-safe.
 
 `dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home-paths` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix.
 
 ## Testing
 
-Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL and no-artifact locator contract tests plus both hook bridge suites pin available and unavailable transcript dialects.
+Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, ignored model `env`, and parent/child isolation. Both hook bridge suites pin the constant degraded transcript dialects.
 
-A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice.
+A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice.
 
 ## Alternatives considered
 
 **Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions.
 
-**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity.
-
 **Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values.
 
-**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale.
-
 **A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable.
 
-**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks.
+**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it.
 
 **A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query API; the registry generalizes to future environment facts without one tool per fact.
 
 ## Consequences
 
-Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them.
+Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them.
 
-The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization.
+The namespace is discoverable but not secret. `DSH_HOME` can reveal a configured root, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts and rely on sandbox/filesystem policy rather than variable secrecy for authorization.

+ 12 - 34
.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 向工具与钩子公开 agent 会话标识和 JSONL 位置
+# Agent Note: 向工具与钩子公开 agent 会话标识
 
 Status: implemented
 
@@ -6,29 +6,12 @@ Status: implemented
 
 ## 问题
 
-agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。
+agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话。恢复、fork 以及并发运行的父子 agent 会让任何来自环境的猜测都不可靠,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。
 
 这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。
 
 ## 决策
 
-在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.zh.md) seam 上增加同步、无副作用的位置查询:
-
-```ts
-import type { SessionHeader } from '@deepseek-ai/dsh-session'
-
-interface SessionLocation {
-  readonly kind: string
-  readonly path: string
-}
-
-interface SessionPersistence {
-  locate(meta: SessionHeader): SessionLocation | undefined
-}
-```
-
-`path` 是 provider 为 `meta` 保留的专用日志本地绝对路径;`kind` 标识其表示。JSONL 使用解析后的 root 与路径 helper 返回 `{ kind: 'jsonl', path }`。无法诚实提供逐 Session 本地产物的仓库外 provider 返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。
-
 面向模型的 bash 包拥有一个 `ctx.shellEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境 API 可供诊断工具和未来的提示词/UI 消费方枚举。
 
 注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层:
@@ -36,15 +19,16 @@ interface SessionPersistence {
 - `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.zh.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。
 - `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。
 - 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。
-- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。
 
-会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。
+transcript(文本记录)位置事实被有意省略。本决策的早期形式还在持久化 seam 上增加了 `locate()` 路径查询,为 `DSH_SESSION_JSONL` 变量和钩子桥接层的 `transcript_path` 提供来源;[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责移除这一半——这些路径只有在禁用压缩时才可读,该 seam 也不再公开产物位置。
+
+需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。
 
 bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `ShellExecRequest.dshEnv`/`ShellExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。
 
 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。
 
-[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话
+[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)为保持协议格式,仍在线上 payload 中保留 `transcript_path` 字段,但始终发送 `''`(Claude Code)/`null`(Codex);这项降级由[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责
 
 ## 同类产品调研
 
@@ -52,36 +36,30 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管
 
 ## 生命周期与持久化语义
 
-新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。
-
-恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。
+新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID`。恢复操作复用已加载的 header,因此 id 不变。fork 和 spawn 会创建新的会话 id。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。注册表受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。
 
 `dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-home-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。
 
 ## 测试
 
-单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL 与无产物定位器约定测试、两套钩子桥接测试均固定 transcript 可用和不可用两种方言。
+单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、忽略模型 `env`,以及父子隔离。两套钩子桥接测试均锁定恒定的降级 transcript 方言。
 
-一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。
+一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id 和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。
 
 ## 考虑过的替代方案
 
 **只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。
 
-**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。
-
 **使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。
 
-**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。
-
 **使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。
 
-**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。
+**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。
 
 **增加面向模型的 `session_info` 工具。** bash 已经提供查询 API,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。
 
 ## 影响
 
-每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。
+每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。
 
-该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。
+该命名空间可被发现,但并非秘密。`DSH_HOME` 可能泄露配置的根目录,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
-2026-08-06-manager-owned-subagent-settlement-delivery.md: 7dfa05c247ee0efb71963e057731c7ff6a5a1989
-2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6bd8836d966c85ee0ef08ec19bbf96a4f72b0dd4
+2026-08-06-manager-owned-subagent-settlement-delivery.md: d06245eacd3b7453a031716b1921015a5e38a25c
+2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: e5259e97f185203ed77ae9427e5523ac29d4162f

+ 4 - 0
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md

@@ -32,6 +32,10 @@ An external `ctx.on('subagent/end')` listener looks more decoupled and is wrong.
 
 Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed.
 
+### Establishment holds ownership open
+
+The owned-child accounting also guards the creation side: `holdOwnership()` pre-registers the child id in a continuation-managed parent's owned set before the establishment or resume awaits (persistence stat, provider preparation, materialization), so an idle parent cannot be judged settled while a caller is still creating or resuming that child — an admitted delivery after settlement would find a stale parent identity. The returned releaser serves only the failure path: it removes just the hold this call added, and once a live Activation for the child exists the ownership edge belongs to that Activation and `finishDisposal`'s `releaseOwnership`. A parent with no Activation needs no hold (only this manager settles parents), and a parent whose own disposal transaction is already open rejects with `ACTIVATION_CLOSING` instead of establishing a child that could never be delivered to.
+
 ### Scheduling
 
 An idle parent gets one ordinary later turn. A busy parent is steered into its nearest step boundary, because `Inbox.claim()` takes the whole next-step batch at one boundary: four children settling together then cost one step rather than four turns. Steering rather than injecting is deliberate — the wake is a no-op while the driver is running, and it closes the window where a driver retires between the status read and the send, which would strand the notice unclaimed until something unrelated woke the parent. This is a correctness rule, not a deployment preference, so it is not a `Config` field.

+ 4 - 0
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md

@@ -32,6 +32,10 @@ Status: implemented
 
 两条规则都有测试固定:把顺序反转或去掉记账,测试就会失败。
 
+### 建立期间保持所有权敞开
+
+被持有子代的记账同样守护创建侧:`holdOwnership()` 会在建立或恢复的各个 await(持久化 stat、提供方准备、实体化)之前,把子代 id 预先登记进由续接管理的父代持有集合,因此当调用方仍在创建或恢复某个子代时,空闲的父代不会被判定为已结算——若在结算之后才接纳投递,将会遇到过期的父代身份。返回的释放器只服务失败路径:它只移除本次调用添加的持有;一旦该子代存在活跃 Activation,所有权边就归属于该 Activation 与 `finishDisposal` 的 `releaseOwnership`。没有 Activation 的父代不需要持有(只有本管理器会结算父代),而自身 dispose 事务已经打开的父代会以 `ACTIVATION_CLOSING` 拒绝,而不是建立一个永远无法收到投递的子代。
+
 ### 调度
 
 空闲父级得到一个普通的后续轮次。繁忙父级则被 steer 到其最近的 step 边界,因为 `Inbox.claim()` 会在一个边界上整批取走 next-step:四个 child 同时结算时因此只消耗一个 step,而不是四个轮次。采用 steer 而非 inject 是刻意的——驱动运行期间该唤醒是空操作,同时它关闭了「驱动在状态读取与发送之间退出」的那个窗口;否则通知会滞留无人认领,直到别的事件唤醒父级。这是正确性规则而非部署偏好,因此不做成 `Config` 字段。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md
-2026-08-10-minimal-read-image-tool.md: be7c24965ee256e4062b5caf32ce7f8c62d9c1ae
-2026-08-10-minimal-read-image-tool.zh.md: f1d09a320e0f44145e4c8681668996d66fbb4898
+2026-08-10-minimal-read-image-tool.md: fa6cbca90e2e20b28412311cf1b0fc34f85368b3
+2026-08-10-minimal-read-image-tool.zh.md: 0629bac744f28408ec7d8b9f8348316c69aa2521

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md

@@ -29,4 +29,4 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged
 
 - The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders.
 - Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes.
-- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages.
+- The tool-result card now renders the image itself through the browser's `tool.call.images` slot (see [the tool-card image results note](2026-08-20-tool-card-image-results.md)); a UI without the attachment presentation plugin shows the result's envelope text.

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md

@@ -29,4 +29,4 @@ Status: implemented
 
 - 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。
 - 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。
-- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理
+- 工具结果卡片现在经由浏览器的 `tool.call.images` 槽位渲染图像本身(见 [tool-card image results 笔记](2026-08-20-tool-card-image-results.zh.md));未组合附件呈现插件的 UI 显示结果的信封文本

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
-2026-08-10-web-session-log-export.md: df80ad4d264d835b2c11973ca61cf143576f11f3
-2026-08-10-web-session-log-export.zh.md: 2af86f371f9e7ed5255bb4e57a8a43427c745fe0
+2026-08-10-web-session-log-export.md: 47686e70c2f961c2d7e5d9ebd54b71b25c3c80b7
+2026-08-10-web-session-log-export.zh.md: 2c12c5271a52e37a6ded95bb65d4eb343072ba41

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md

@@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
 
 ## Decision
 
-- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
+- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `export(id)` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
 - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`.
 - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
 - The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md).
@@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
 ## Consequences
 
 - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-<sanitized-id>.zip` and archive paths sanitize ids before they can shape entries.
-- `supportsRawArtifacts` explicitly separates backend capability from session absence: a backend without one raw artifact per Session reports `false` and the concrete `readRaw` default rejects, while the shipped JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response.
+- The export needs no seam capability: each log is read through a persistence read handle and serialized here as canonical JSONL, so any mounted backend exports identically ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) records the removal of the earlier verbatim-artifact surface). Absence is decided by a `stat` preflight (absent session → 404). `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response.
 - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button.
 - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap.

+ 3 - 3
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md

@@ -10,8 +10,8 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
 
 ## 决策
 
-- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
-- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。
+- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务上的 `export(id)` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
+- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。
 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
 - 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。
 
@@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
 ## 后果
 
 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-<sanitized-id>.zip`,归档路径在塑造条目前会先净化会话 id。
-- `supportsRawArtifacts` 明确区分后端能力与会话缺失:没有每 Session 一份原始工件的后端报告 `false`,具体 `readRaw` 默认会拒绝;交付的 JSONL 覆写报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。
+- 导出不需要 seam 能力:每份日志经由持久化读句柄读取,并在此处序列化为规范 JSONL,因此任何挂载的后端导出完全一致(早先逐字工件表面的移除由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)记录)。缺失由一次 `stat` 预检判定(会话缺失 → 404)。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。
 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。
 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md
+2026-08-20-tool-card-image-results.md: 7659bc46858b0d65c300e51020867a7c156c4d11
+2026-08-20-tool-card-image-results.zh.md: 10f4b3182d8302ce34660b6b4abf6bd47d968baf

+ 57 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md

@@ -0,0 +1,57 @@
+# Agent Note: Tool-card image results
+
+Status: implemented
+
+English | [中文](2026-08-20-tool-card-image-results.zh.md)
+
+## Problem
+
+A settled `read_image` call rendered its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}` — instead of showing the image.
+
+Two independent gaps produced that. `read_image` declared no `output.presentationMeta`, so no presentation metadata told a client card how to present the reference — the tool card printed the raw result content as text. Separately, the tool-card layer had no image concept: `packages/client/ui-tool/src` contained no occurrence of `image` or `attachment`, and `ToolRow`'s card slots were terminal, diff, read, search, and web.
+
+The rendering capability already existed, but only on the message path. `MessageImages` draws durable image groups for user and assistant history through the `conversation.message.images` slot. That asymmetry explains a confusing observation: a **nested** `read_image` displayed correctly, because `execute` defers a real user message for a nested call, while a top-level call — which returns the image only as tool-result content — did not.
+
+## Decision
+
+**Host.** `read_image` gains an `output.presentationMeta` that persists `{ path }` — the path only.
+
+The attachment reference is deliberately not persisted there. The settled `content` already carries the image block with the complete reference, and that block is what a `tools/post-execute` hook replaces when it legitimately rewrites a result. A second copy in `meta` would therefore be a duplicate record of one fact, and a stale one exactly when the content changed — the card would keep showing an image the result no longer returns. The path is the one fact the content does not carry as a structured field: the model-facing envelope embeds the backend-resolved path as text, and the client never parses that text.
+
+No `presentResult`, and no new member of the closed `ToolResultView` union. Client cards derive from raw event fields, and host `presentCall`/`presentResult` values never enter the client ([ui-tool README](../../../../packages/client/ui-tool/README.md)), so a result-view arm would have been a public type extension with no consumer.
+
+**Client.** `imageCardModel` derives the card the way every other first-party card does: `parsedToolCall` validates the call head and its `file_path`, `block.meta` supplies the path — a nested call (a `read_image` dispatched from inside `run_code`) persists no `meta`, so the call's own `file_path` argument fills the label — the attachment reference is narrowed out of the result's own image block, and the envelope is located in the same content. It matches its own envelope by shape rather than using `singleResultText`, because that helper accepts only a lone text block while an image read returns `[text envelope, image block]` — matching by shape also means content another layer prepended is never mistaken for the envelope.
+
+The narrowing checks the attachment id for existence only. The id is opaque and provider-owned: the local store mints content addresses, but consumers must neither parse that representation nor assume its shape, and a provider may change it without notice. Pattern-matching the local form would reject a legitimate id from an alternative store and silently degrade every image card in that deployment.
+
+`ToolRow` gains an `image` card slot, and `read_image` gets a keyed toolview that declares the Tool-owned `tool.call.images` slot as its child and renders the gallery through it. The tool layer never loads or authorizes anything: the row supplies only the references it derived from the result plus the `loadImage` loader the chat node now passes down (`ChatNodeOwnerProps.loadImage`), and the attachment presentation plugin fills the slot with the same gallery it uses for message images. An image-bearing tool therefore registers a keyed toolview (`read_image` is the template for the row assembly and card model; the `tool.call.images` child declaration is not reusable verbatim, because a slot is declared by exactly one entry); the generic fallback keeps its flattened text.
+
+The card keeps the derived envelope text below the gallery. That is not redundancy: `tool.call.images` renders nothing in a deployment without the attachment presentation plugin, and that empty gallery must not leave a blank card — measured, not assumed: a slot returning `null` rendered an empty container with no visible text.
+
+`read_image` joins the `read` variant and gets its own locale title key. Left unclassified it fell to `others`, which titles the row generically and derives no `filePath` (only read/write/edit variants do), so the openable path the row advertises would never have been openable.
+
+`read` and `read_image` are the same single-file card row with different card material, so their shared assembly lives in `read-family-row.tsx` rather than being copied.
+
+## Alternatives considered
+
+- **Add `card: 'image'` arm to `ToolResultView` and a `presentResult`.** This is what the first version did. Client cards derive from raw events and host presentation values never reach the client, so the arm had no consumer — an extension of a closed public union that nothing read. Dropped in favour of `presentationMeta` alone.
+- **Pass a rendering closure down (the `renderMessageImages` pattern).** The next version reused `ChatNodeOwnerProps.renderMessageImages` as a `renderImages` owner prop, mirroring what `AssistantMarkdown` and the message rows do. Review rejected it: the client rule forbids new ReactNode-valued owner props, and the compliant shape is a slot the tool layer itself declares. With `loadImage` down-threaded from the chat node, the row renders `tool.call.images` directly and no rendering capability crosses the owner boundary.
+- **Render the image on the generic fallback too.** The slot design cannot: a slot is declared by exactly one entry, and the fallback component is not a registered entry, so it has no dispatch seat for a child it did not declare. The keyed row is the only image render site; future image tools register their own.
+- **Use `singleResultText` like the read card.** It accepts only a lone text block by design, and an image read returns two, so the card matches its own envelope shape instead.
+- **Persist the reference in `meta` as well.** The first version did, and it read the card from there. Review pointed out the duplication, and the recorded log confirmed it: `meta.image` and the content block's `attachment` were byte-identical. Reading from the content instead leaves one record and follows a post-execute replacement.
+- **Validate the attachment id against `sha256:<hex>`.** Tried, then reverted: it contradicts the documented opacity of `AttachmentId` and would break any deployment whose store mints another shape.
+- **Give the image card its own primitive in `ui-primitives`.** Rejected as duplication — the message gallery's fit rules, crop anchors, and lightbox are the behavior a card needs.
+
+## Verification
+
+`read-image.spec.ts` covers the metadata projection, the omitted display name, and a real execution whose persisted reference matches what the attachment store committed. `image-card.client.spec.tsx` covers the derivation from metadata and envelope, path relativization, opaque ids from alternative stores, every rejection branch of the defensive narrowing, the running/error declines, the nested-call derivation with its argument-path fallback, the keyed row render site dispatching `tool.call.images` with the loader, keyed registration with the child-slot declaration, and the empty-slot fallback.
+
+Negative controls were run against each assertion group before it was kept: removing the variant classification, disabling the image render branch, mistyping the registrant key, restoring the `sha256:` id pattern, and pointing the card's text back at the row's flattened output each turned the intended assertion red.
+
+## Consequences
+
+A `read_image` result now renders as the image on the tool card for both a top-level call and a nested one (a call dispatched from inside `run_code`), and the tool card gains an image kind. A nested call already displayed its image on the message path — `execute` defers a real user message for it — but its own tool row stayed generic; the card derivation now covers it too, with the call's `file_path` argument standing in for the persisted path. The image kind is not automatic from the metadata alone: the card also requires the `tool.call.images` slot to be filled (the attachment presentation plugin) and a keyed toolview for the tool, because the model narrows the call head to `read_image` and the slot is rendered from a declared child entry.
+
+The persisted presentation metadata adds one small `{ path }` record per image read to the session log. The attachment reference is not in the log as metadata at all — it lives in the settled result content's image block — and the image bytes themselves are never logged, because the store is content-addressed and the block carries only the attachment id.
+
+Because the card derives from `block.meta` plus the settled content, a session logged before this change carries no image metadata and replays as the generic text card. That is the documented fallback for every raw-event-derived card, not a special case here.

+ 57 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md

@@ -0,0 +1,57 @@
+# Agent Note:工具卡片的图像结果
+
+Status: implemented
+
+[English](2026-08-20-tool-card-image-results.md) | 中文
+
+## 问题
+
+已结算的 `read_image` 调用在工具卡片里把原始附件对象当字面文本渲染出来——`{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}`——而不是显示图像本身。
+
+这由两个彼此独立的缺口造成。`read_image` 没有声明 `output.presentationMeta`,因此没有任何呈现元数据告诉客户端卡片如何展示引用——工具卡片把原始结果内容当作文本打了出来。另一方面,工具卡片层完全没有图像概念:`packages/client/ui-tool/src` 中 `image` 和 `attachment` 一次都没出现,而 `ToolRow` 的卡片槽只有 terminal、diff、read、search、web。
+
+渲染能力其实已经存在,但只接在消息路径上。`MessageImages` 通过 `conversation.message.images` 槽位为用户与助手历史绘制持久图像组。这个不对称解释了一个容易困惑的现象:**嵌套的** `read_image` 能正确显示,因为嵌套调用时 `execute` 会 defer 一条真正的用户消息;而顶层调用只把图像作为工具结果内容返回,就不显示。
+
+## 决定
+
+**宿主侧。** `read_image` 获得只持久化 `{ path }` 的 `output.presentationMeta`——仅路径一项。
+
+附件引用有意不写在那里。已结算的 `content` 本身就带着含完整引用的 image 块,而当 `tools/post-execute` 钩子合法重写结果时,被替换的正是那个块。因此在 `meta` 里再存一份就是同一事实的重复记录,且恰恰在内容变化时变成过期副本——卡片会继续显示结果已不再返回的图像。路径是 content 唯一不作为结构化字段携带的事实:面向模型的信封把后端解析出的路径写成文本,而客户端从不解析那段文本。
+
+不加 `presentResult`,也不给封闭的 `ToolResultView` 联合新增成员。客户端卡片从原始 event 字段派生,宿主的 `presentCall`/`presentResult` 值永不进入客户端(见 [ui-tool README](../../../../packages/client/ui-tool/README.zh.md)),因此新增一个 result-view 分支等于扩展一个无人读取的封闭公共联合。
+
+**客户端侧。** `imageCardModel` 按其他所有第一方卡片的方式派生:`parsedToolCall` 校验调用头与其 `file_path`,`block.meta` 提供路径——嵌套调用(从 `run_code` 内部派发的 `read_image`)不持久化 `meta`,于是用调用自身的 `file_path` 参数补足标签——附件引用从结果自己的 image 块中防御式 narrow 出来,信封在同一内容中定位。它按形状匹配自己的信封而不用 `singleResultText`,因为那个 helper 只接受单个文本块,而图像读取返回 `[text envelope, image block]`——按形状匹配同时意味着其他层前置的内容永远不会被误认为信封。
+
+该 narrowing 只检查附件 id 是否存在。id 是不透明且由提供方拥有的:本地存储铸造内容地址,但消费者既不得解析该表示、也不得假定其形状,且提供方可以不经通知改变它。按本地形式做模式匹配会拒绝替代存储铸造的合法 id,并让该部署中每个图像卡片静默降级。
+
+`ToolRow` 获得 `image` 卡片槽,`read_image` 获得按 key 注册的 toolview,并在其 registration 上把 Tool 自有的 `tool.call.images` 槽位声明为子槽,通过它渲染图库。工具层自己既不加载也不授权:这一行只提供从结果派生出的引用,以及聊天节点新下传的 `loadImage` loader(`ChatNodeOwnerProps.loadImage`),附件呈现插件用与消息图像相同的图库填充该槽位。因此携带图像的工具需要注册按 key 的 toolview(`read_image` 是行装配与 card model 的模板;`tool.call.images` 子槽声明不能逐字复用,因为一个槽位只能由一个 entry 声明);generic fallback 保留压平文本。
+
+卡片在图库下方保留派生出的信封文本。这不是冗余:在未组合附件呈现插件的部署里 `tool.call.images` 什么都不渲染,而空图库不能留下空白卡片——这是实测而非假设:用一个返回 `null` 的槽位探测,渲染出的是空容器,没有任何可见文本。
+
+`read_image` 归入 `read` variant 并获得自己的 locale 标题 key。不分类时它落到 `others`,标题变成通用文案且不派生 `filePath`(只有 read/write/edit variant 会派生),于是该行声称可点击打开的路径永远不可点击。
+
+`read` 与 `read_image` 是同一种单文件卡片行、只是卡片材料不同,因此它们共享的装配放在 `read-family-row.tsx` 而不是复制一份。
+
+## 考虑过的备选方案
+
+- **给 `ToolResultView` 加 `card: 'image'` 分支并实现 `presentResult`。** 第一版就是这么做的。客户端卡片从原始 event 派生、宿主呈现值不进入客户端,因此该分支没有消费方——等于扩展一个无人读取的封闭公共联合。改为只用 `presentationMeta`。
+- **向下传递渲染闭包(`renderMessageImages` 模式)。** 下一版复用了 `ChatNodeOwnerProps.renderMessageImages` 作为 `renderImages` owner prop,与 `AssistantMarkdown` 和消息行的做法一致。review 拒绝了它:客户端规则禁止新增 ReactNode-valued owner props,合规形态是工具层自己声明的槽位。把 `loadImage` 从聊天节点下传后,这一行直接渲染 `tool.call.images`,不再有任何渲染能力穿过 owner 边界。
+- **在 generic fallback 上也渲染图像。** 槽位设计做不到:一个槽位只能由一个 entry 声明,而 fallback 组件不是已注册 entry,没有为未声明的子槽提供 dispatch 席位。按 key 的行是唯一图像渲染点;将来的图像工具注册自己的行。
+- **像 read 卡片那样用 `singleResultText`。** 它按设计只接受单个文本块,而图像读取返回两块,因此卡片改为按自己的信封形状匹配。
+- **在 `meta` 里也持久化引用。** 第一版就是这么做的,卡片也从那里读取。review 指出了这处重复,实录日志也证实了:`meta.image` 与 content 中 image 块的 `attachment` 逐字节相同。改从 content 读取后只剩一份记录,并且会跟随 post-execute 的替换。
+- **按 `sha256:<hex>` 校验附件 id。** 试过后撤回:它与 `AttachmentId` 文档化的不透明性相矛盾,并会让任何采用其他 id 形状的部署失效。
+- **在 `ui-primitives` 里给图像卡片做专属 primitive。** 作为重复实现否决——消息图库的适配规则、裁剪锚点和灯箱正是卡片需要的行为。
+
+## 验证
+
+`read-image.spec.ts` 覆盖元数据投影、省略显示名,以及一次真实执行——其持久化的引用与附件存储实际提交的一致。`image-card.client.spec.tsx` 覆盖从元数据与信封的派生、路径相对化、来自替代存储的不透明 id、防御式 narrowing 的每个拒绝分支、running/error 两种拒绝、嵌套调用派生及其参数路径回退、按 key 的行渲染点(携带 loader 分发 `tool.call.images`)、带子槽声明的按 key 注册,以及空槽位降级。
+
+每组断言在保留之前都跑过负例:移除 variant 分类、让图像渲染分支失效、把 registrant 的 key 打错、恢复 `sha256:` id 模式、把卡片文本指回行的压平输出——每一项都让目标断言变红。
+
+## 后果
+
+`read_image` 的结果现在在工具卡片上渲染为图像,顶层调用与嵌套调用(从 `run_code` 内部派发的调用)皆然;工具卡片获得一种图像种类。嵌套调用此前已在消息路径上显示图像——`execute` 会为它 defer 一条真正的用户消息——但它自己的工具行仍是 generic;现在卡片派生也覆盖它,用调用自身的 `file_path` 参数替代持久化路径。图像种类并不由元数据单独自动产生:卡片还要求 `tool.call.images` 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 `read_image`,槽位只能从声明的子 entry 渲染。
+
+持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 `{ path }` 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。
+
+由于卡片从 `block.meta` 派生,本次改动之前记录的会话没有图像元数据,会以通用文本卡片重放。这是每个从原始 event 派生的卡片都遵循的既有降级路径,不是这里的特例。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md
-2026-08-22-standard-acp-automation-controls.md: 5523fd547dd850e2e52eb818ef418df5a325106e
-2026-08-22-standard-acp-automation-controls.zh.md: 23140eb3e76b26716239d0f53fe3206efd87baa9
+2026-08-22-standard-acp-automation-controls.md: dd1e39b84f942417640f741f17a211276b67872c
+2026-08-22-standard-acp-automation-controls.zh.md: be54850a933e4e728c672d76595fbf900bdda833

+ 1 - 1
.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md

@@ -32,7 +32,7 @@ Complete ACP lifecycle support requires session persistence. `session/list` read
 
 Persistence deliberately treats `create(meta)` as a live registration: the shipped JSONL provider creates no artifact until the first event append. That default removes abandoned empty sessions, but ACP cannot inherit it because `session/new` publishes a session identity before any prompt and the process may stop after the success response without receiving `session/close`. The bridge materializes only after Agent and MCP composition succeeds and before returning `session/new`; failed composition remains residue-free, while every returned id survives restart.
 
-`ensureMaterialized(session)` accepts the exact live Session so the coordinator first flushes it, then serializes header-only materialization on the existing per-session write chain using the immutable registered header. JSONL writes one header frame; an out-of-tree provider must materialize equivalent header state atomically or reject the operation. Repeat calls are idempotent. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss.
+The bridge materializes through the ordinary durability barrier: `ctx.sessions.flush(session)` reaches the session's write handle, whose `flush` writes header-only materialization when nothing has been appended. JSONL writes one header frame; an out-of-tree provider must materialize equivalent header state atomically or reject the operation. Repeat calls are idempotent. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss.
 
 ## Standard configuration options
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md

@@ -32,7 +32,7 @@
 
 持久化有意把 `create(meta)` 视为 live registration:交付的 JSONL provider 在首次追加事件前不创建 artifact。该默认行为会移除被放弃的空会话,但 ACP 不能继承它,因为 `session/new` 会在任何提示词出现前公布会话身份,而进程可能在返回成功响应后、收到 `session/close` 前停止。桥接层只在 Agent 和 MCP 组合成功后、返回 `session/new` 前执行实体化;组合失败仍不留下残留物,每个已返回 id 则都能在重启后继续存在。
 
-`ensureMaterialized(session)` 接收确切 live Session,使 coordinator 先 flush 该会话,再通过现有 per-session 写入链,使用已注册的不可变 header 串行执行仅 header 实体化。JSONL 写入一个 header frame;仓库外 provider 必须原子实体化等价 header 状态,否则拒绝该操作。重复调用幂等。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。
+bridge 经由普通的持久性屏障实体化:`ctx.sessions.flush(session)` 抵达该会话的写句柄,其 `flush` 在尚无任何追加时写入仅 header 实体化。JSONL 写入一个 header frame;仓库外 provider 必须原子实体化等价 header 状态,否则拒绝该操作。重复调用幂等。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。
 
 ## 标准配置选项
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md
-2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02
-2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4
+2026-08-27-web-at-mention-discovery-and-row-content.md: defe3eaffbfe111b383467354fb21f088c9475c0
+2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 236cefc55f96f7d918e754622ebc7ae3b7759792

+ 1 - 1
.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md

@@ -34,7 +34,7 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio
 
 **Fold the missing titles from their logs, memoized per cold log.** Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, `turn/end`, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke.
 
-**Read a cold session's title through `sessionQuery.observeSession` or `persistence.readFrom`.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read.
+**Read a cold session's title through `sessionQuery.observeSession` or a persistence read handle.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read.
 
 **Debounce the candidate fetch.** Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as *slower* while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md

@@ -34,7 +34,7 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的
 
 **从日志折叠缺失的标题,并按冷日志记忆化。** 先实现了,评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算:今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint,而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据,不是发现路径每次击键都该付的形状。
 
-**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。
+**通过 `sessionQuery.observeSession` 或持久化读句柄读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。
 
 **给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending,因此尾部防抖会延长骨架状态,输入时读起来更慢。折叠成本移除后,往返时间不再值得一个定时器;在新 generation 下保留上一批行是另一个决定,带有误选后果,此处不做。
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.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-31-pr-opened-issue-start-dates.md
-2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48
-2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79
+2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e
+2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1

+ 8 - 6
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md

@@ -6,26 +6,28 @@ English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
 
 ## Problem
 
-The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
+The organization-level `Start date` Issue field records when work begins, but adding an Issue to the Issue Project or linking it from a pull request does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
 
 Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request.
 
 ## Decision
 
-The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty.
+The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured organization Issue Date field only when the current value is empty.
 
-The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
+The configuration names the field exposed in the Project and the time zone. The Project field must resolve to an organization Issue Date field; the workflow reads its Issue value and updates it through `updateIssueFieldValue`. Missing configuration fails when the policy module loads; a missing field, a non-Date or Project-local field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
 
 [Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement.
 
 ## Verification
 
-[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
+[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, Issue-field discovery, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the `updateIssueFieldValue` variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
 
 ## Alternatives considered
 
 **Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date.
 
+**Use a Project-local Date field.** A Project field would allow different dates for the same Issue in different Projects and would not appear on the Issue itself. Work begins for the Issue rather than for one Project membership, so the organization Issue field owns the value.
+
 **Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads.
 
 **Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics.
@@ -34,6 +36,6 @@ The configuration names the Project field and time zone. Missing configuration f
 
 ## Consequences
 
-Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests.
+Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests. The date follows the Issue across organization Projects that expose the field.
 
-The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.
+The empty-value read makes retries idempotent in ordinary operation. The Issue-field mutation has no compare-and-set precondition, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.

+ 8 - 6
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md

@@ -6,26 +6,28 @@ Status: implemented
 
 ## 问题
 
-Issue Project 使用 `Start date` 字段记录已规划工作的开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
+组织级 `Start date` Issue 字段记录工作开始时间,但把 Issue 加入 Issue Project 或从 PR 关联它都不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
 
 如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。
 
 ## 决策
 
-Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。
+Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的组织级 Issue Date 字段。
 
-配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
+配置指定 Project 中显示的字段和时区。该 Project 字段必须解析为组织级 Issue Date 字段;工作流读取它的 Issue 值并通过 `updateIssueFieldValue` 更新。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型或是 Project 局部字段、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
 
 [由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。
 
 ## 验证
 
-[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、Issue 字段发现、空值写入、已有值保留、Project item 缺失、字段配置无效和 `updateIssueFieldValue` 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
 
 ## 考虑过的替代方案
 
 **使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。
 
+**使用 Project 局部 Date 字段。** Project 字段允许同一个 Issue 在不同 Project 中使用不同日期,并且不会显示在 Issue 自身。工作是针对 Issue 开始,而不是针对某次 Project 归属开始,因此由组织级 Issue 字段持有该值。
+
 **处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。
 
 **仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。
@@ -34,6 +36,6 @@ Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date
 
 ## 后果
 
-只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。
+只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。日期会随 Issue 出现在组织内显示该字段的各个 Project 中。
 
-空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。
+空值读取使重试在通常情况下保持幂等。Issue 字段 mutation 没有比较并设置前提,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。

+ 2 - 2
.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.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-19-drop-mutable-session-summary.md
-2026-06-19-drop-mutable-session-summary.md: 73fc99b4d948da90b78d66e801915a2fb38e4749
-2026-06-19-drop-mutable-session-summary.zh.md: 97b1b098489cccfbbfdbd48439d640b554a26c37
+2026-06-19-drop-mutable-session-summary.md: b93566914a490b5054b865e51a8fda21925be1b5
+2026-06-19-drop-mutable-session-summary.zh.md: 9a36ba6f20e38a571099372c47588024cfea4853

+ 1 - 1
.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md

@@ -22,7 +22,7 @@ Delete the mutable session summary entirely. `SessionSummary` and the `SessionMe
 
 Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it.
 
-The removal narrows the public service contract and JSONL on-disk format; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also simplifies the [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator needs no `updateSummary` hook, and an out-of-tree provider can reuse the same summary-free orchestration.
+The removal narrows the public service contract and JSONL on-disk format; the summary was a deliberate forward-looking design, not an accident; and `SessionHeader` stands where the original Agent Note described `SessionMeta`, which is why the summary vanished. It also simplified the then-current [shared persistence write coordinator](../../archived/architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, that orchestration needed no `updateSummary` hook.
 
 ## No migration
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。
 
-这次移除收窄公开服务约定与 JSONL 磁盘格式;摘要是有意为未来设计的结果,而非意外;原 Agent Note 描述 `SessionMeta` 之处由 `SessionHeader` 承担,这就是摘要消失的原因。它还简化了[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.zh.md):没有可变摘要后,协调器不需要 `updateSummary` 钩子,仓库外 provider 可复用相同的无摘要编排
+这次移除收窄公开服务约定与 JSONL 磁盘格式;摘要是有意为未来设计的结果,而非意外;原 Agent Note 描述 `SessionMeta` 之处由 `SessionHeader` 承担,这就是摘要消失的原因。它也简化了当时的[共享持久化写入协调器](../../archived/architecture/2026-06-18-shared-persistence-write-coordinator.md):没有可变摘要后,那套编排不需要 `updateSummary` 钩子
 
 ## 无需迁移
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md
+2026-08-27-persistence-export-and-pre-release-trims.md: ed01023023e9d7817b08e275492b7ec244b893a5
+2026-08-27-persistence-export-and-pre-release-trims.zh.md: 1e31a5836c2761c3d6fb5dbb68b30e8691c1fc69

+ 46 - 0
.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md

@@ -0,0 +1,46 @@
+# Agent Note: Persistence export() and pre-release read-path trims
+
+Status: implemented
+
+English | [中文](2026-08-27-persistence-export-and-pre-release-trims.zh.md)
+
+## Problem
+
+The session-persistence seam is moving to a handle-based API with cross-process ownership (open/read/append/flush/close per session). Before that swap, the old seam carried surfaces the new design drops or replaces, each with its own consumers and tests: a consumer-facing path query (`locate`), a capability-flagged verbatim read (`supportsRawArtifacts` + `readRaw`), ~300 lines of same-version legacy event-shape migration in the coordinator, and a `locate`-based size gate for the session list's cold blank probe. Removing them inside the seam swap would bloat an already large change; removing them first shrinks the core swap to the seam itself.
+
+## Decision
+
+**One verbatim export method.** `SessionPersistence.export(id, signal?)` returns the session's raw artifact (`SessionRawArtifact`: parsed header, logical filename, decoded verbatim text) or `undefined`. The base default resolves `undefined`; JSONL overrides it with the former `readRaw` behavior. `supportsRawArtifacts` and `readRaw` do not exist. The apiproxy ZIP download distinguishes an unsupported backend (session present in `list()` but `export()` undefined → 501) from an absent session (404) by list membership instead of a capability flag. Superseded by the [handle seam](../architecture/2026-08-27-handle-based-session-persistence.md): the WebUI download needs only the logical log, so `export()` was removed entirely and the ZIP route serializes JSONL from a read handle, ending the 501 path.
+
+**No consumer-facing path query.** `locate` is not a service method. `SessionLocation` survives only as refusal diagnostics: the JSONL backend derives the artifact path internally so `SessionFormatUnsupportedError` can point at the raw log a build refused. The three consumer features built on `locate` are removed or degraded, not ported:
+
+- `DSH_SESSION_JSONL` no longer exists; shell-env registers no persistence contributor. The variable was only honest with `compression: 'none'` — the default `.jsonl.zstd` artifact is unreadable from bash.
+- The Claude Code / Codex hook bridges keep `transcript_path` in the wire payload for protocol shape but always send `''` / `null`. Hook scripts could not parse the compressed artifact either.
+- The `locate`-based size gate for the session-controller cold blank probe is deleted. The probe itself runs on stat metadata: the [handle-based seam](../architecture/2026-08-27-handle-based-session-persistence.md)'s `stat()`/`list()` snapshots carry optional `eventCount`/`sizeBytes`, and session-controller bounds the probe with `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`; a cold session past both thresholds, or on a backend offering neither hint, reports `blank: false` (unknown).
+
+**Legacy event-shape migration is deleted.** Reads validate current v0 records only. Retired event types (`steering/message`, `mode/set`, `request/header-delta`) refuse through the read-side vocabulary gate as `SessionFormatUnsupportedError`. Pre-identity message payloads and the `request/header` `fallback` reason refuse through session validation — surfaced as `SessionPersistenceCorruptionError` on the load/inspect path and as the plain validation error on `readFrom`. Pre-react-loop turn envelopes have no validator: a stale `turn/start.trigger` field and the coarse `aborted`/`disposed` turn-end reasons load unprojected as extension-shaped data, the documented merge-extensible fall-through that the contract test "preserves extension turn/end reasons outside the closed reason set" pins. This consolidates and supersedes the pre-identity-message and pre-react-loop import notes; their record is preserved below.
+
+## Consolidated record of the deleted same-version imports
+
+Two shipped read-side imports existed because message identity (2026-07) and the react-loop refactor (2026-08) changed durable payloads without bumping `SESSION_FORMAT_VERSION`: the coordinator normalized four exact pre-identity message payloads (minting deterministic `legacy-message:<id>:<seq>` identities, with tool-result replacements inheriting their target's id) and projected pre-react-loop shapes (`steering/message` → identified `user/message`, `turn/start.trigger` removal, terminal-reason mapping including a persistence-only `{ kind: 'legacy' }` aborted cause). Both were read-only, exact-shape, and deliberately not a general v0 compatibility layer; their rejected alternatives were stranding first-party sessions, rewriting stored logs in place (violates append-only), and minting unstable identities.
+
+They no longer justify their surface: no tagged release exists, the covered logs are months-old development artifacts, and the mechanism cost ~300 coordinator lines, per-event normalization on every read, a `readFrom` whole-prefix fallback for suffix reads, and fixture suites in three backends. The capability given up: pre-identity logs refuse to load (loudly, with the raw-log path in the refusal) instead of resuming, and pre-react-loop logs either refuse (when they carry the retired `steering/message` type) or load with their stale turn-envelope fields passed through instead of projected to current shapes. Reintroduction condition: after the first tagged release, a durable format change bumps `SESSION_FORMAT_VERSION` and ships an explicit migration under the version gate — never another same-version exact-shape exception. Absence is verified by the vocabulary-refusal tests in the coordinator contract and backend specs.
+
+## Alternatives considered
+
+**Keep `locate` as (or move it to) a separate export-location service.** Rejected: all three path consumers are only functional with compression disabled, so the seam would preserve a half-broken feature; verbatim access needs are served by `export()`.
+
+**Keep the `supportsRawArtifacts` capability flag beside `export()`.** Rejected: `undefined` plus a list-membership check carries the same information with one seam member instead of three.
+
+**Keep the migrations until the first tagged release.** Rejected: the pre-release stance ("remove at the first tagged release") already refuses old on-disk formats everywhere else; the migrations' only beneficiaries are development-era logs.
+
+## Consequences
+
+The seam ahead of the handle refactor is smaller: one export method, no path query, no capability flag, and a coordinator without migration tables. The costs are recorded degradations: hook payload `transcript_path` is never populated (a durable consumer gap in both hook bridge READMEs), the session list marks never-opened cold sessions blank only within the snapshot-metadata probe thresholds, and development-era logs written before the react-loop refactor refuse to load. The 501/404 split for ZIP export costs one `list()` call on the undefined-export path only.
+
+## Related
+
+- [Retain ignorable external session events](../architecture/2026-08-30-retain-ignorable-external-session-events.md) — owns the read-side-only unknown-type gate this change leans on.
+- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the seam these trims shrink.
+- [Zstandard JSONL session logs](../architecture/2026-07-19-zstandard-jsonl-session-logs.md) — owns the frame container these reads and appends flow through.
+- [Session identity and log location](../feature/2026-07-10-agent-session-identity-and-log-location.md) — partially superseded: its `DSH_SESSION_ID` and shell-env registry decisions stand; its `locate`/`DSH_SESSION_JSONL`/`transcript_path` decisions are removed here.

+ 46 - 0
.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md

@@ -0,0 +1,46 @@
+# Agent Note: 持久化 export() 与预发布读取路径精简
+
+Status: implemented
+
+[English](2026-08-27-persistence-export-and-pre-release-trims.md) | 中文
+
+## 问题
+
+会话持久化 seam 正在迁移到基于句柄、具备跨进程所有权的 API(每个会话的 open/read/append/flush/close)。在这次替换之前,旧 seam 携带着新设计将放弃或替换的多个表面,每个都有自己的消费方与测试:面向消费方的路径查询(`locate`)、由能力标志控制的逐字读取(`supportsRawArtifacts` + `readRaw`)、协调器中约 300 行的同版本 legacy 事件形态迁移,以及会话列表冷空白探测使用的基于 `locate` 的大小门槛。若在 seam 替换中一并移除它们,会让本已庞大的变更进一步膨胀;先移除它们能把核心替换缩小到 seam 本身。
+
+## 决策
+
+**单一逐字导出方法。**`SessionPersistence.export(id, signal?)` 返回该会话的原始产物(`SessionRawArtifact`:解析后的 header、逻辑文件名、解码后的逐字文本)或 `undefined`。基类默认解析为 `undefined`;JSONL 用原先的 `readRaw` 行为覆盖它。`supportsRawArtifacts` 与 `readRaw` 不复存在。apiproxy 的 ZIP 下载通过 list 成员关系而非能力标志区分不受支持的后端(会话存在于 `list()` 中但 `export()` 为 undefined → 501)与不存在的会话(404)。已被[句柄 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md)取代:WebUI 下载只需要逻辑日志,因此 `export()` 被整体移除,ZIP 路由改为从读句柄序列化 JSONL,501 路径随之终结。
+
+**不提供面向消费方的路径查询。**`locate` 不是服务方法。`SessionLocation` 仅作为拒绝诊断保留:JSONL 后端在内部推导工件路径,使 `SessionFormatUnsupportedError` 能指向构建所拒绝的原始日志。基于 `locate` 构建的三项消费方功能被移除或降级,而非移植:
+
+- `DSH_SESSION_JSONL` 不复存在;shell-env 不再注册持久化贡献方。该变量只有在 `compression: 'none'` 时才是诚实的——默认的 `.jsonl.zstd` 产物无法从 bash 读取。
+- Claude Code/Codex 钩子桥接层为保持协议格式,仍在线上 payload 中保留 `transcript_path`,但始终发送 `''`/`null`。钩子脚本同样无法解析压缩产物。
+- session-controller 冷空白探测中基于 `locate` 的大小门槛被删除。探测本身运行在 stat 元数据之上:[基于句柄的 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md) 的 `stat()`/`list()` 快照携带可选的 `eventCount`/`sizeBytes`,session-controller 以 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes` 限定探测;超过两个阈值的冷会话,或位于两种提示都不提供的后端上的冷会话,报告 `blank: false`(未知)。
+
+**删除 legacy 事件形态迁移。**读取只校验当前 v0 记录。已废弃的事件类型(`steering/message`、`mode/set`、`request/header-delta`)经由读取侧词汇门禁以 `SessionFormatUnsupportedError` 拒绝。消息标识机制之前的消息 payload 与 `request/header` 的 `fallback` 原因经由会话校验拒绝——在 load/inspect 路径上表现为 `SessionPersistenceCorruptionError`,在 `readFrom` 上表现为普通校验错误。react-loop 重构之前的轮次 envelope 没有校验器:过时的 `turn/start.trigger` 字段与粗粒度的 `aborted`/`disposed` 轮次结束原因会作为扩展形态数据不经投影地加载,这正是约定测试 "preserves extension turn/end reasons outside the closed reason set" 所钉住的、有文档记载的可合并扩展 fall-through。此举合并并取代了 pre-identity-message 与 pre-react-loop 两份导入 Note;其记录保存在下文。
+
+## 已删除的同版本导入的合并记录
+
+曾存在两个已上线的读取侧导入,因为消息标识机制(2026-07)与 react-loop 重构(2026-08)在未升级 `SESSION_FORMAT_VERSION` 的情况下改变了持久 payload:协调器会归一化四种精确的 pre-identity 消息 payload(铸造确定性的 `legacy-message:<id>:<seq>` 标识,工具结果替换项继承其目标的 id),并投影 pre-react-loop 形态(`steering/message` → 带标识的 `user/message`、移除 `turn/start.trigger`、终止原因映射——包括仅存在于持久化中的 `{ kind: 'legacy' }` aborted 原因)。二者都是只读、精确形态匹配,并且有意不构成通用的 v0 兼容层;当时被否决的替代方案是弃置第一方会话、就地改写已存储日志(违反仅追加)以及铸造不稳定的标识。
+
+它们已不足以支撑自身的表面:尚无任何已打标签的发布,所覆盖的日志是数月前的开发期产物,而该机制的代价是协调器约 300 行代码、每次读取的逐事件归一化、后缀读取时 `readFrom` 回退到整个前缀,以及三个后端中的 fixture(测试前置数据)套件。放弃的能力是:pre-identity 日志会拒绝加载(明确报错,并在拒绝信息中给出原始日志路径),而不是继续恢复;pre-react-loop 日志则要么被拒绝(当其携带已废弃的 `steering/message` 类型时),要么在加载时把过时的轮次 envelope 字段原样透传,而不是投影为当前形态。重新引入条件:在第一个已打标签的发布之后,持久格式变更升级 `SESSION_FORMAT_VERSION` 并在版本门禁之下提供显式迁移——绝不再开一个同版本精确形态的例外。协调器约定与后端 spec 中的词汇拒绝测试验证该机制确实不存在。
+
+## 考虑过的替代方案
+
+**保留 `locate`(或将其移入独立的导出位置服务)。**不予采用:三个路径消费方都只有在禁用压缩时才可用,seam 会因此保留一个半失效的功能;逐字访问需求已由 `export()` 满足。
+
+**在 `export()` 之外保留 `supportsRawArtifacts` 能力标志。**不予采用:`undefined` 加上 list 成员检查携带相同的信息,却只需一个 seam 成员而非三个。
+
+**把迁移保留到第一个已打标签的发布。**不予采用:预发布立场(「在第一个已打标签的发布时移除」)已在其他所有地方拒绝旧的磁盘格式;这些迁移的唯一受益者是开发期日志。
+
+## 后果
+
+句柄重构之前的 seam 更小:一个导出方法、没有路径查询、没有能力标志,以及不含迁移表的协调器。代价是已记录在案的降级:钩子 payload 的 `transcript_path` 永远不会被填充(两个钩子桥接层 README 中记录的持久消费方缺口);会话列表只在快照元数据探测阈值之内才把从未打开过的冷会话标记为空白;react-loop 重构之前写入的开发期日志会拒绝加载。ZIP 导出的 501/404 区分只在 `export()` 为 undefined 的路径上多一次 `list()` 调用。
+
+## 相关资料
+
+- [保留可忽略的外部会话事件](../architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)——拥有本变更所依赖的仅读取侧未知类型门禁。
+- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.zh.md)——拥有本次精简所缩小的 seam。
+- [Zstandard JSONL 会话日志](../architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md)——拥有这些读取与追加流经的帧容器。
+- [会话标识与日志位置](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)——部分被取代:其 `DSH_SESSION_ID` 与 shell-env 注册表决策仍然有效;其 `locate`/`DSH_SESSION_JSONL`/`transcript_path` 决策在此移除。

+ 2 - 2
.agents/notes/implemented/simplification/2026-08-30-jsonl-only-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/simplification/2026-08-30-jsonl-only-session-persistence.md
-2026-08-30-jsonl-only-session-persistence.md: f21fb89e3747dffd043d42ace2c05bbe521f3069
-2026-08-30-jsonl-only-session-persistence.zh.md: 4100e576ccdcd46443e12a22cfec6dd3d3495317
+2026-08-30-jsonl-only-session-persistence.md: 6282544a6132ed66eab3b1a174edbf9b67e1c8ad
+2026-08-30-jsonl-only-session-persistence.zh.md: 4785ff72d599404fa6d6b897fda520c761150b11

+ 1 - 1
.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md

@@ -12,7 +12,7 @@ The SQLite full-text Session-query provider is not an alternative authoritative
 
 ## Decision
 
-`@deepseek-ai/dsh-session-persistence-jsonl` is the sole first-party implementation of `ctx.sessionPersistence`. The abstract Service Definition and `PersistenceCoordinator` remain backend-neutral so an out-of-tree provider can implement the same service, but the repository owns and tests one authoritative physical Session format.
+`@deepseek-ai/dsh-session-persistence-jsonl` is the sole first-party implementation of `ctx.sessionPersistence`. The abstract Service Definition remains backend-neutral so an out-of-tree provider can implement the same service, but the repository owns and tests one authoritative physical Session format.
 
 The `@deepseek-ai/dsh-session-persistence-sqlite` package, its schema resources, backend-specific tests, configuration surface, and Windows differential lane are absent. Cross-package persistence tests use the real JSONL provider or an owner-local fake. `@deepseek-ai/dsh-session-query-sqlite` remains the optional FTS5 query provider over a separate rebuildable database, and `@deepseek-ai/dsh-storage-sqlite` remains the generic domain-KV provider.
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md

@@ -12,7 +12,7 @@ SQLite 全文 Session-query provider 不是另一种权威 store。它通过 `ct
 
 ## Decision
 
-`@deepseek-ai/dsh-session-persistence-jsonl` 是 `ctx.sessionPersistence` 唯一的 first-party 实现。抽象 Service Definition 与 `PersistenceCoordinator` 保持后端无关,使仓库外 provider 仍可实现同一服务,但仓库只拥有并测试一种权威 Session 物理格式。
+`@deepseek-ai/dsh-session-persistence-jsonl` 是 `ctx.sessionPersistence` 唯一的 first-party 实现。抽象 Service Definition 保持后端无关,使仓库外 provider 仍可实现同一服务,但仓库只拥有并测试一种权威 Session 物理格式。
 
 仓库不再包含 `@deepseek-ai/dsh-session-persistence-sqlite` package、其 schema resource、后端专用测试、配置接口与 Windows differential lane。跨 package 持久化测试使用真实 JSONL provider 或 owner-local fake。`@deepseek-ai/dsh-session-query-sqlite` 继续作为可选 FTS5 query provider 使用独立、可重建的数据库,`@deepseek-ai/dsh-storage-sqlite` 继续作为通用 domain-KV provider。
 

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
-2026-07-29-durable-last-activity-index.md: e391b5cd0281c66221d9ca96920a294c98c7e16f
-2026-07-29-durable-last-activity-index.zh.md: 453e8a772d2ad8fe4ab2affcfd7049d86da258c5
+2026-07-29-durable-last-activity-index.md: 8022bb50530dc565813ba4f4c07342b348125eed
+2026-07-29-durable-last-activity-index.zh.md: be84ad73ba9f685bdff7cd80a4ed6fdb46006588

+ 3 - 3
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md

@@ -10,7 +10,7 @@ A cold (persisted, unattached) session has no authoritative stored answer to "wh
 
 The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff.
 
-An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read used for metadata verification makes eligible small-artifact recency exact, but it does not make large-log ordering exact.
+An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately reads no logs: cold summaries come from the projection cache alone, so cold recency is only as fresh as the cache.
 
 Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround.
 
@@ -58,7 +58,7 @@ Three questions must be answered before implementation, and none of them is sett
 
 ## Related
 
-- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to small-artifact metadata verification.
+- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering and defines the interim cache-only cold summary this proposal would make exact.
 - [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable.
 - [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
-- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
+- [Handle-based session persistence](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md) — the write-handle append path a stored field would hook into.

+ 3 - 3
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md

@@ -10,7 +10,7 @@ Status: proposed
 
 网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer,以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍。
 
-已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间,但不能让大日志的排序精确
+已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取任何日志:冷摘要只来自 projection cache,因此冷最近时间的新旧只取决于 cache
 
 让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 workaround 中。
 
@@ -58,7 +58,7 @@ Status: proposed
 
 ## 相关
 
-- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为小产物 metadata 验证
+- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)——移除 mtime 排序,并定义了本提案将使之精确的、仅依赖 cache 的过渡冷摘要
 - [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md)——让 mtime 不适用的非 prompt 写入之一。
 - [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
-- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md)——一个已存储字段将挂入的那条追加路径。
+- [基于句柄的会话持久化](../../implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)——一个已存储字段将挂入的那条写句柄追加路径。

+ 6 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md
+2026-08-27-port-tool-owned-render.md: 4ab27142fdc2d13d7d152c21c2f246e340fb4711
+2026-08-27-port-tool-owned-render.zh.md: 9b5ece1a222551a308f6008e316fb4c251c36d88

+ 35 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md

@@ -0,0 +1,35 @@
+# Agent Note: Port tool-owned render into current DSH APIs
+
+Status: proposed
+
+English | [中文](2026-08-27-port-tool-owned-render.zh.md)
+
+## Problem
+
+The `dsh-tool-owned-render` prototype (`Chinesezjc/dsh-tool-owned-render`) ships tool-owned render registrants for `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch`, written against an older API where `ToolCallBlock` exposed `callView` / `resultView` and the client received host `presentResult` output. Current master derives client cards from raw `block.call` / `block.content` / `block.meta`, and `ctx.slots` requires the `@deepseek-ai/dsh-client-ui-renderer/client` module augmentation. A direct merge of the prototype does not typecheck, so its registrants cannot ship without a port.
+
+## Proposal
+
+- Add `packages/client/tool-owned-render` as a workspace package.
+- Port the `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch` registrants to derive from current `ToolCallBlock` fields.
+- Add a `read_image` registrant using the same ToolCard/Segment primitives.
+- Wire `ctx.slots` type augmentation through `dsh-client-ui-renderer`.
+- Keep PR #2828 mergeable while this port proceeds separately.
+
+## Alternatives considered
+
+- **Merge the prototype and fix its type errors in place** — rejected: every registrant would have to be re-derived from the current `ToolCallBlock` fields anyway, so the port is the same work with the obsolete `callView` / `resultView` contract already gone.
+- **Fold the port into PR #2828** — rejected: the image card is one feature with a defined scope, and a second package plus five more registrants would enlarge the review surface of an already large PR.
+
+## Acceptance criteria
+
+- `packages/client/tool-owned-render` exists as a workspace package.
+- The ported registrants derive card state from current `ToolCallBlock` fields and typecheck on master.
+- A `read_image` registrant renders through the same primitives as `read`.
+- The `ctx.slots` type augmentation resolves through `dsh-client-ui-renderer`.
+- PR #2828 merges independently of this port.
+
+## Risks
+
+- The port may not reproduce the prototype's exact visual output, because the current card primitives differ from the old `callView` / `resultView` contract.
+- API drift while the port proceeds can stale this proposal; the acceptance criteria are re-checked against master at port time.

+ 35 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 把 tool-owned render 移植到当前 DSH API
+
+状态:proposed
+
+[English](2026-08-27-port-tool-owned-render.md) | 中文
+
+## 问题
+
+`dsh-tool-owned-render` 原型(`Chinesezjc/dsh-tool-owned-render`)带有 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 的 tool-owned render 注册项,基于旧 API 编写:`ToolCallBlock` 暴露 `callView` / `resultView`,客户端能拿到 host `presentResult` 输出。当前 master 从原始 `block.call` / `block.content` / `block.meta` 推导客户端卡片,`ctx.slots` 也需要 `@deepseek-ai/dsh-client-ui-renderer/client` 模块增强。直接合并原型不能通过类型检查,因此这些注册项不经移植无法发布。
+
+## 提案
+
+- 新增 `packages/client/tool-owned-render` workspace 包。
+- 把 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 注册项移植到从当前 `ToolCallBlock` 字段推导。
+- 增加 `read_image` 注册项,使用同一套 ToolCard/Segment 原语。
+- 通过 `dsh-client-ui-renderer` 接通 `ctx.slots` 类型增强。
+- 移植单独推进,保持 PR #2828 可合并。
+
+## 已考虑的替代方案
+
+- **直接合并原型并就地修复类型错误** — 否决:每个注册项反正都要按当前 `ToolCallBlock` 字段重新推导,移植就是同一份工作,只是旧的 `callView` / `resultView` 契约已不存在。
+- **把移植并入 PR #2828** — 否决:image 卡片是一个范围明确的单一功能,再加一个新包和五个注册项会扩大本已很大的 PR 的审查面。
+
+## 验收标准
+
+- `packages/client/tool-owned-render` 作为 workspace 包存在。
+- 移植后的注册项从当前 `ToolCallBlock` 字段推导卡片状态,并在 master 上通过类型检查。
+- `read_image` 注册项与 `read` 使用同一套原语渲染。
+- `ctx.slots` 类型增强通过 `dsh-client-ui-renderer` 解析。
+- PR #2828 独立于本移植合并。
+
+## 风险
+
+- 移植可能无法复现原型的精确视觉输出,因为当前卡片原语与旧的 `callView` / `resultView` 契约不同。
+- 移植推进期间 API 继续漂移会使本提案过时;验收标准在移植时按当时的 master 重新核对。

+ 2 - 2
.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md
-2026-07-04-prune-dead-core-spine-api.md: 554244a67cdbe26d90aca1b4bd7a61e1ca0dc7ec
-2026-07-04-prune-dead-core-spine-api.zh.md: a0a2520ecf31a4626b629779ca07b7debf8d062d
+2026-07-04-prune-dead-core-spine-api.md: b26a39ef8d18306b9d42894eaab4925c69c37c8b
+2026-07-04-prune-dead-core-spine-api.zh.md: b72e902504f66fc48947ea71c75a257ad724e643

+ 0 - 1
.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md

@@ -20,7 +20,6 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime
 | ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. |
 | `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. |
 | `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. |
-| `PersistenceCoordinator.inits`, provider `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep the JSONL provider and `SessionHeader`. |
 | `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. |
 | `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. |
 | `compactRegion`'s separate `session` argument | The fixed caller passes the same object already present as `agent.session`; the model-visible mount API can also call the method, but accepting two identities permits a mounted plugin to provide an incoherent pair. | Keep the manual-region API while deliberately narrowing it to `agent.session` as the one source of truth. |

+ 0 - 1
.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md

@@ -20,7 +20,6 @@ Status: proposed
 | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 |
 | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 |
 | `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制机制和测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 |
-| `PersistenceCoordinator.inits`、provider `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留 JSONL provider 与 `SessionHeader`。 |
 | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 |
 | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 约定。 |
 | `compactRegion` 的独立 `session` 参数 | 固定调用方传入的对象就是 `agent.session` 中已有的对象;模型可见的 mount API 也可以调用该方法,但同时接受两个独立对象,会让挂载的插件传入不一致的组合。 | 保留手动 region API,同时有意将其收窄为以 `agent.session` 为唯一真源。 |

+ 2 - 2
.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.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/rejected/simplification/2026-06-20-fold-session-persistence-interface.md
-2026-06-20-fold-session-persistence-interface.md: 3079e9495cdbf4dd698fccc65d3c2fdada29cfc1
-2026-06-20-fold-session-persistence-interface.zh.md: 3eff594a0d2377c52bbee54a1d7e3b26a015078e
+2026-06-20-fold-session-persistence-interface.md: 58c6aa8f5b3b77f146e18d285ebf3fe1e36b0778
+2026-06-20-fold-session-persistence-interface.zh.md: 9fb53d16fced3d17ee0a76a05df04628ac9d8c95

+ 1 - 1
.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.md

@@ -22,7 +22,7 @@ The implementing PR should update the [capability seams](../../implemented/archi
 - `dsh-session` exports the persistence service type, coordinator, and contract helpers.
 - JSONL and SQLite backend packages depend on `dsh-session` directly.
 - `agent-loop` resume uses the session-owned service key.
-- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session/session-persistence/README.md) explain why backend implementations remain separate.
+- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [handle-based session persistence](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md), and [package docs](../../../../packages/session/session-persistence/README.md) explain why backend implementations remain separate.
 
 ## What we give up
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio