Jelajahi Sumber

perf(session): separate indexed and snapshot log reads

_Kerman 3 minggu lalu
induk
melakukan
5660f44d29
100 mengubah file dengan 450 tambahan dan 352 penghapusan
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml
  11. 1 1
      .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
  12. 1 1
      .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml
  14. 32 0
      .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md
  15. 32 0
      .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md
  16. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml
  17. 1 1
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
  18. 1 1
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md
  19. 2 2
      .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml
  20. 1 1
      .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md
  21. 1 1
      .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml
  23. 1 1
      .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
  24. 1 1
      .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md
  25. 2 2
      .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml
  26. 1 1
      .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md
  27. 1 1
      .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md
  28. 2 2
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
  29. 1 1
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
  30. 1 1
      .agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
  31. 1 1
      apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts
  32. 1 1
      apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
  33. 1 1
      apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
  34. 3 3
      apps/cli/tests/profiles/headless/tests/ptc.e2e.ts
  35. 1 1
      apps/cli/tests/profiles/headless/tests/resume.e2e.ts
  36. 1 1
      apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts
  37. 1 1
      apps/web/tests/chat-continuous-conversation.e2e.ts
  38. 11 11
      apps/web/tests/chat-long-interactions.e2e.ts
  39. 1 1
      apps/web/tests/chat-scroll-fixture.ts
  40. 1 1
      apps/web/tests/complex-history.perf.ts
  41. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  42. 1 1
      apps/web/tests/markdown-cjk-strong.e2e.ts
  43. 1 1
      apps/web/tests/markdown-images.e2e.ts
  44. 1 1
      apps/web/tests/markdown-inline-code-links.e2e.ts
  45. 1 1
      apps/web/tests/markdown-wide-table.e2e.ts
  46. 1 1
      apps/web/tests/math-rendering.e2e.ts
  47. 1 1
      apps/web/tests/minimal-preset.snapshot.ts
  48. 1 1
      apps/web/tests/produced-file-mentions.e2e.ts
  49. 1 1
      apps/web/tests/produced-files.e2e.ts
  50. 2 2
      apps/web/tests/reference-composer.e2e.ts
  51. 2 2
      apps/web/tests/scaffold.ts
  52. 8 8
      apps/web/tests/schedule-after.e2e.ts
  53. 1 1
      apps/web/tests/seeded-history.e2e.ts
  54. 4 4
      apps/web/tests/settings-chrome.e2e.ts
  55. 1 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  56. 2 2
      apps/web/tests/subagent-interrupt.e2e.ts
  57. 2 2
      docs/subsystems/session.i18n.yaml
  58. 23 8
      docs/subsystems/session.md
  59. 23 8
      docs/subsystems/session.zh.md
  60. 1 1
      packages/acp/acp/tests/approval.spec.ts
  61. 2 2
      packages/acp/acp/tests/bridge.spec.ts
  62. 5 5
      packages/acp/acp/tests/turns.spec.ts
  63. 2 2
      packages/api/remotes/tests/built-lib.e2e.ts
  64. 1 1
      packages/api/session-controller/src/commands.ts
  65. 1 1
      packages/api/session-controller/src/history.ts
  66. 1 1
      packages/api/session-controller/src/index.ts
  67. 7 1
      packages/api/session-controller/tests/agent.host.spec.ts
  68. 1 1
      packages/api/session-controller/tests/session-cold.host.spec.ts
  69. 6 6
      packages/api/session-controller/tests/session-fork.host.spec.ts
  70. 2 2
      packages/api/session-controller/tests/session-history-journal.host.spec.ts
  71. 2 2
      packages/api/session-controller/tests/session-projections.host.spec.ts
  72. 1 1
      packages/api/session-controller/tests/session-rename.host.spec.ts
  73. 16 11
      packages/api/session-controller/tests/transport.host.spec.ts
  74. 9 5
      packages/bundle/headless/src/index.ts
  75. 1 1
      packages/compaction/command-compact/tests/command-compact.spec.ts
  76. 1 1
      packages/compaction/command-compact/tests/loader-composition.spec.ts
  77. 6 7
      packages/compaction/compaction-basic/src/region.ts
  78. 26 26
      packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
  79. 9 9
      packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
  80. 30 30
      packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
  81. 1 1
      packages/compaction/compaction-tool-result-pruner/src/index.ts
  82. 4 4
      packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
  83. 3 2
      packages/compaction/compaction/src/invariant.ts
  84. 5 11
      packages/compaction/compaction/src/tool-pairing.ts
  85. 3 3
      packages/compaction/compaction/tests/compaction.spec.ts
  86. 8 8
      packages/compaction/compaction/tests/invariant.spec.ts
  87. 16 29
      packages/compaction/compaction/tests/tool-pairing.spec.ts
  88. 2 2
      packages/context/agent-instructions/src/index.ts
  89. 1 1
      packages/context/agent-instructions/src/state.ts
  90. 3 3
      packages/context/agent-instructions/tests/agent-instructions.e2e.ts
  91. 34 34
      packages/context/agent-instructions/tests/agent-instructions.spec.ts
  92. 1 1
      packages/context/session-reference/tests/session-reference.spec.ts
  93. 3 2
      packages/context/time-context/src/index.ts
  94. 4 3
      packages/context/time-context/src/invariant.ts
  95. 13 13
      packages/context/time-context/tests/time-context.spec.ts
  96. 2 2
      packages/context/tmux-context/tests/tmux-context.spec.ts
  97. 1 1
      packages/core/agent-loop/src/invariant.ts
  98. 2 2
      packages/core/agent-loop/src/runtime-context.ts
  99. 1 1
      packages/core/agent-loop/tests/agent-initiator.spec.ts
  100. 4 4
      packages/core/agent-loop/tests/agent.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-11-dev-invariants-over-deep-readonly.md
-2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c
-2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff
+2026-06-11-dev-invariants-over-deep-readonly.md: 91d9ba2e459a02dc65b95a179d11e2f14af7e28d
+2026-06-11-dev-invariants-over-deep-readonly.zh.md: 750aee7ec543979f88a6bb606c00bd789de45c51

+ 3 - 3
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md

@@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel
 
 `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
 
-The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
+The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
 
 This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
 
@@ -48,12 +48,12 @@ Freezing history only when an invariants plugin is installed would make the core
 
 ### Clone only when deriving messages
 
-Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
+Detaching `deriveMessages()` would protect the most common request path but leave other readers of `snapshotEvents()`, `eventAt()`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
 
 ## Consequences
 
 - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
-- `session.events` exposes stable immutable snapshots instead of the private growing array.
+- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array.
 - Request-side mutation cannot reach stored history through derived messages.
 - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
 - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.

+ 3 - 3
.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md

@@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 
 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
 
-被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。
+被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。
 
 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
 
@@ -48,12 +48,12 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行
 
 ### 仅在派生消息时克隆
 
-分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
+分离 `deriveMessages()` 能保护最常见的请求路径,但 `snapshotEvents()`、`eventAt()` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
 
 ## 后果
 
 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
-- `session.events` 暴露稳定的不可变快照,而非持续增长的私有数组。
+- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径
 - 请求侧的修改无法通过派生消息触及已存储的历史。
 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df
+2026-07-25-web-client-session-scope-and-provide-channel.md: 94d13a4cdc9771a7a500f5e6d5158e42ee67374a
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: c462e1f574fe1bc2db14672395a27e459e6ab7a8

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste
 
 A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
 
-- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
+- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
 - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
 - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
   - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
 
 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
 
-- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
+- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
   - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.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-26-packed-chunk-rows-by-default.md
-2026-07-26-packed-chunk-rows-by-default.md: 14da6b3cbe650e80118e7c960c96bf618acd1e48
-2026-07-26-packed-chunk-rows-by-default.zh.md: f62a8e52a67adc960ac3150552594b4f061e6205
+2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222
+2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md

@@ -18,7 +18,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa
 
 ### Logical events and physical rows
 
-The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics.
+The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters the Session log or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics.
 
 The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md

@@ -18,7 +18,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封
 
 ### 逻辑事件与物理行
 
-JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。
+JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 Session 日志,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。
 
 JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.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-large-session-jsonl-restore-pipeline.md
-2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547
-2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 2cd0d2ca20074d6adb0735db08071638ae2ced88
+2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59
+2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md

@@ -41,7 +41,7 @@ Borrowed seeds used by ordinary creation and fork paths still take a JSON snapsh
 - **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix.
 - **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics.
 - **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal.
-- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them.
+- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and Session read methods promise immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md

@@ -41,7 +41,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
 - **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。
 - **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。
 - **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。
-- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。
+- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。
 
 ## 后果
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml

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

+ 32 - 0
.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md

@@ -0,0 +1,32 @@
+# Agent Note: Session log reads state their materialization cost
+
+Status: implemented
+
+English | [中文](2026-08-21-session-log-read-intent.zh.md)
+
+## Problem
+
+An all-purpose `Session.events` accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separately by the [source-owned session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md).
+
+## Decision
+
+`Session` exposes three cost-specific read operations. `seq` reads the current length in constant time, `eventAt(seq)` reads one event in constant time, and `snapshotEvents(fromSeq?, toSeqExclusive?)` explicitly materializes a frozen array for consumers that need array operations or a stable materialized range. Sequence parameters are non-negative log positions, not `Array.prototype.slice` offsets from the end. Recurring domain state such as the selected agent preset is read from a Session projection instead of rescanning live history.
+
+The complete current snapshot is cached until append because repeated whole-log consumers can share the same immutable array. A range snapshot copies only the selected references and is not cached: arbitrary range caching would retain unbounded arrays and require an eviction policy. Previously returned snapshots remain stable because accepted events are immutable and a snapshot array never grows after append.
+
+Recurring domain-state reads use [session projections](2026-08-19-session-projection-state-and-client-views.md) when the required value can be maintained incrementally. Raw-log snapshots remain appropriate for persistence, export, replay, and consumers whose output is the event sequence itself. This API makes materialization visible but does not attempt to eliminate every full-log fold in the same change.
+
+## Alternatives considered
+
+**Keep a cached `events` array accessor.** This preserves ordinary array syntax but makes scalar and indexed reads appear cheap while an append can turn either into a whole-log copy.
+
+**Return a custom immutable cut with array-like traversal operations.** A captured length could provide a stable constant-time cut over the growing log, but the abstraction would reimplement selected array semantics and keep expanding as callers request more operations. Explicit indexed reads, explicit materialization, and projections cover the shipped intents with a smaller public API.
+
+**Cache every materialized range or maintain an incremental chunked snapshot.** Range caching needs retention and eviction rules, while a chunked public representation changes consumers and serialization for a cost that many callers avoid through indexed reads or projections. These representations remain options if measured full-snapshot consumers justify them.
+
+## Consequences
+
+- Length and single-event reads do not copy the log.
+- Full and ranged snapshots retain an explicit linear cost proportional to the selected event count.
+- Consumers choose between raw history and incrementally maintained state at the call site.
+- The public API does not emulate an array; callers materialize only when they need array operations.

+ 32 - 0
.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: 会话日志读取显式表达物化成本
+
+Status: implemented
+
+[English](2026-08-21-session-log-read-intent.md) | 中文
+
+## 问题
+
+通用的 `Session.events` 访问器会在每次 append 后的首次读取中隐藏一次与数组大小成正比的复制。完整冻结快照可以缓存,但流式输出的每个新事件都会使缓存失效,因此只需要日志长度或单个事件的调用方也可能反复复制数百万个引用。返回值不可变并不要求每种读取意图都承担该成本;事件不可变性由[源端拥有的会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)另行负责。
+
+## 决策
+
+`Session` 提供三种成本不同的读取操作。`seq` 以常数时间读取当前长度,`eventAt(seq)` 以常数时间读取一个事件,`snapshotEvents(fromSeq?, toSeqExclusive?)` 则为需要数组操作或稳定物化区间的消费方显式物化冻结数组。序列参数是非负日志位置,不是从末尾计算的 `Array.prototype.slice` 偏移量。所选 agent preset 这类重复读取的领域状态来自会话投影,而不是反复扫描活跃日志。
+
+当前完整快照会缓存到下一次 append,使重复读取整个日志的消费方可以共享同一个不可变数组。区间快照只复制所选引用且不缓存:缓存任意区间会保留数量无界的数组,并要求额外的淘汰策略。先前返回的快照保持稳定,因为已接受的事件不可变,且快照数组不会在 append 后增长。
+
+当所需值可以增量维护时,重复的领域状态读取使用[会话投影](2026-08-19-session-projection-state-and-client-views.zh.md)。持久化、导出、回放,以及输出本身就是事件序列的消费方仍适合使用原始日志快照。此 API 使物化成本显式可见,但不会在同一项改动中消除所有完整日志折叠。
+
+## 曾考虑的替代方案
+
+**保留缓存的 `events` 数组访问器。** 这保留了普通数组语法,但会让标量读取和索引读取看似廉价,而一次 append 就可能使其中任何一次读取变成完整日志复制。
+
+**返回提供类数组遍历操作的自定义不可变 cut。** 捕获长度可以在持续增长的日志上提供稳定且常数时间的 cut,但随着调用方要求更多操作,该抽象会不断重新实现所选数组语义。显式索引读取、显式物化和投影以更小的公开 API 覆盖了已经交付的读取意图。
+
+**缓存每个物化区间或维护增量分片快照。** 区间缓存需要保留与淘汰规则,而公开的分片表示会改变消费方和序列化方式;许多调用方已通过索引读取或投影避开这项成本。若实测的完整快照消费方证明有必要,仍可考虑这些表示。
+
+## 后果
+
+- 长度读取和单事件读取不会复制日志。
+- 完整快照和区间快照仍有与所选事件数量成正比的显式线性成本。
+- 消费方在调用点选择原始历史或增量维护的状态。
+- 公开 API 不模拟数组;调用方只在需要数组操作时物化。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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-29-human-transcript-append-origin.md
-2026-07-29-human-transcript-append-origin.md: 72cafb5a1a149bcdb73d885d4e4fc4ac01e48cd2
-2026-07-29-human-transcript-append-origin.zh.md: 79fcc56b7b60082ebd2946d70419a659ff5687b7
+2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc
+2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md)
 
 The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only `compaction/summary` event and the replacement that cites it.
 
-Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection.
+Nothing was lost from the log. `Session.snapshotEvents()` still returned every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection.
 
 ## Decision
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 终端与宿主历史网关都把模型可见的 surface 当作 transcript(文本记录)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志 `compaction/summary` 事件与引用它的替换之间。
 
-日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。
+日志本身没有丢失任何内容。`Session.snapshotEvents()` 仍返回每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。
 
 ## 决策
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.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-05-context-meter-blind-to-compaction.md
-2026-08-05-context-meter-blind-to-compaction.md: daf11b41236943d5fb35e9363c128ce470c7f4e9
-2026-08-05-context-meter-blind-to-compaction.zh.md: 6ea2f8814c4731fdf7c08d8b867cd7086ceccdbf
+2026-08-05-context-meter-blind-to-compaction.md: a23e51771c74e350fd3fb04e3a23ab3cdfb6092e
+2026-08-05-context-meter-blind-to-compaction.zh.md: 12fd41af0d8c8864a120f26d91148543f74b2e2b

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md

@@ -27,7 +27,7 @@ This reverses the "the ring, header, and bar length stay provider-exact" half of
 
 ## Alternatives considered
 
-**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold.
+**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.eventAt(seq)`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold.
 
 **Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md

@@ -27,7 +27,7 @@ AFTER  compact:  ring=4%  header=~4227/100000   rows=[system 18, tools 0, messag
 
 ## 备选方案
 
-**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
+**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.eventAt(seq)`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。
 
 **在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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-05-context-form-vocabulary.md
-2026-08-05-context-form-vocabulary.md: 9f20614dcd2ed0164efb51f938de9f74657d3bc3
-2026-08-05-context-form-vocabulary.zh.md: ebdabeab5a074f21e8fac9625a78c99cc401a6d3
+2026-08-05-context-form-vocabulary.md: c4f1414ad90ddefbac2d216f9109c95726769203
+2026-08-05-context-form-vocabulary.zh.md: 57af2be2b25ce9af36d7551fb271ef577e0f7b4c

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md

@@ -39,7 +39,7 @@ That move also relocates catalog **identity**: the republish digest now covers t
 
 Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
 
-The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
+The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.snapshotEvents()`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
 
 Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md

@@ -39,7 +39,7 @@ Status: implemented
 
 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
 
-生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
+生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.snapshotEvents()` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
 
 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-05-feedback-gated-session-telemetry.md
-2026-08-05-feedback-gated-session-telemetry.md: ce07e63681a659fb9bf758a24301c531f3f27e39
-2026-08-05-feedback-gated-session-telemetry.zh.md: 9657c7dc9243377233f20b265569829b06162fb5
+2026-08-05-feedback-gated-session-telemetry.md: 9dab135029bd4a5dd209157b48b6f7dca4040224
+2026-08-05-feedback-gated-session-telemetry.zh.md: 824aa1a04f3854163fdd16f4db7a7593540976e0

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md

@@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent
 
 The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records.
 
-Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration.
+Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when `session.eventAt(event.seq)` returns that exact `feedback/record` object. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。
 
-模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。
+模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是 `session.eventAt(event.seq)` 返回完全相同的 `feedback/record` 对象。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/feature/2026-07-06-recallable-compaction.md
-2026-07-06-recallable-compaction.md: 7309297af84a43a29e03feae815ac8c937a9da6a
-2026-07-06-recallable-compaction.zh.md: 890493ae7d6a7b9066dd071b2cc8f642e9c84c86
+2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448
+2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e

+ 1 - 1
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md

@@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi
 - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor.
 - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint.
 
-Both read `exec.agent.session.events` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
+Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools.
 
 ### Cache and cost
 

+ 1 - 1
.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md

@@ -46,7 +46,7 @@ Status: proposed
 - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。
 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。
 
-两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
+两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。
 
 ### 缓存与成本
 

+ 1 - 1
apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts

@@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test
     await waitForIdle(ctx, agent)
 
     // The agent claims success…
-    const summary = finalText([...agent.session.events]).toLowerCase()
+    const summary = finalText([...agent.session.snapshotEvents()]).toLowerCase()
     expect(summary.length).toBeGreaterThan(0)
 
     // …and the world agrees: the test passes when WE run it, and the test

+ 1 - 1
apps/cli/tests/profiles/headless/tests/compaction.e2e.ts

@@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
       }], source: { kind: 'user' } }))
     await waitForIdle(ctx, agent)
 
-    const events = [...agent.session.events]
+    const events = [...agent.session.snapshotEvents()]
 
     // A compaction ran: the start…end bracket landed in the real log.
     const starts = events.filter(e => e.type === 'compaction/start')

+ 1 - 1
apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts

@@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
     agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } }))
     await waitForIdle(ctx, agent)
 
-    const events = [...agent.session.events]
+    const events = [...agent.session.snapshotEvents()]
     const calls = events.filter(event => event.type === 'tool/call')
     expect(calls.length).toBeGreaterThan(0)
     expect(calls.some(event => event.data.name === 'bash')).toBe(true)

+ 3 - 3
apps/cli/tests/profiles/headless/tests/ptc.e2e.ts

@@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr
         + 'and return only the joined string.',
       }], source: { kind: 'user' } }))
     await waitForIdle(ctx, agent)
-    const events: SessionEvent[] = [...agent.session.events]
+    const events: SessionEvent[] = [...agent.session.snapshotEvents()]
 
     // The wire contract: every request this session made offered EXACTLY ONE
     // tool — run_code (the logged header snapshots the assembled list).
@@ -418,11 +418,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr
       }], source: { kind: 'user' } }))
     await waitForIdle(ctx, handle.agent)
 
-    const events: SessionEvent[] = [...handle.agent.session.events]
+    const events: SessionEvent[] = [...handle.agent.session.snapshotEvents()]
     const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
     const outerResult = events.find(event => event.type === 'tool/result')
     const workspaceContext = await vi.waitFor(() => {
-      const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced'
+      const splice = handle.agent.session.snapshotEvents().findLast(event => event.type === 'agent/inbox/spliced'
         && event.data.inserted.some(message => message.source.kind === 'agent-instructions'))
       const inserted = splice?.type === 'agent/inbox/spliced'
         ? splice.data.inserted.find(message => message.source.kind === 'agent-instructions')

+ 1 - 1
apps/cli/tests/profiles/headless/tests/resume.e2e.ts

@@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     await waitForIdle(ctx, resumed)
 
     // The model recalls it — only possible from the resumed history.
-    expect(finalText([...resumed.session.events])).toContain(SECRET)
+    expect(finalText([...resumed.session.snapshotEvents()])).toContain(SECRET)
   }, 180_000)
 })

+ 1 - 1
apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts

@@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a
       + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } }))
     await waitForIdle(ctx, agent)
 
-    const events = [...agent.session.events]
+    const events = [...agent.session.snapshotEvents()]
 
     // The model actually called the tool.
     const calls = events.filter(event => event.type === 'tool/call')

+ 1 - 1
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -330,7 +330,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
     }
 
     if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
-    expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
+    expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => (
       event.type === 'turn/end' && event.data.reason.kind === 'completed'
     ))).toHaveLength(TURN_COUNT)
     expect(sessionEvents.flatMap(event =>

+ 11 - 11
apps/web/tests/chat-long-interactions.e2e.ts

@@ -181,13 +181,13 @@ describe('web e2e: long Chat interaction contract', () => {
     const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
     const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
     const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
-    const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
-    const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
+    const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker)
+    const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker)
     const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
     const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
-    const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
-    const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
-    const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
+    const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker)
+    const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker)
+    const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => (
       event.type === 'turn/end' && event.data.turn === BRANCH_TURN
     ))
     if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`)
@@ -311,9 +311,9 @@ describe('web e2e: long Chat interaction contract', () => {
       .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
     if (child === undefined) throw new Error('message branch did not create a child session')
     expect(child.session.header.seedLength).toBe(boundary.seq + 1)
-    expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
-    expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
-    expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
+    expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true)
+    expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
+    expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
 
     const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
       .getByRole('button').last()
@@ -330,11 +330,11 @@ describe('web e2e: long Chat interaction contract', () => {
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
     expect(await composer.textContent()).toBe('')
     expect(await composer.isEnabled()).toBe(true)
-    expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
-    expect(child.session.events.filter(event => (
+    expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
+    expect(child.session.snapshotEvents().filter(event => (
       event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
     ))).toHaveLength(1)
-    const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
+    const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => (
       event.type === 'turn/end'
     ))
     expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })

+ 1 - 1
apps/web/tests/chat-scroll-fixture.ts

@@ -164,7 +164,7 @@ function fixtureLog(session: Session): string {
       cwd: '{{cwd}}',
       delegationDepth: 0,
     }),
-    ...session.events.map(event => JSON.stringify(event)),
+    ...session.snapshotEvents().map(event => JSON.stringify(event)),
     '',
   ].join('\n')
 }

+ 1 - 1
apps/web/tests/complex-history.perf.ts

@@ -313,7 +313,7 @@ function fixtureLog(session: Session): string {
   }
   return [
     JSON.stringify(header),
-    ...session.events.map(event => JSON.stringify(event)),
+    ...session.snapshotEvents().map(event => JSON.stringify(event)),
     '',
   ].join('\n')
 }

+ 1 - 1
apps/web/tests/goal-command-presentation.e2e.ts

@@ -109,7 +109,7 @@ describe('web e2e: /goal human transcript presentation', () => {
 
     const sessions = scaffold.ctx.sessions.list()
     expect(sessions).toHaveLength(1)
-    const persisted = sessions[0]?.events ?? []
+    const persisted = sessions[0]?.snapshotEvents() ?? []
     expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
       .map(event => event.type)).toEqual(['command/run', 'command/done'])
     expect(persisted.some(event => event.type === 'user/message')).toBe(false)

+ 1 - 1
apps/web/tests/markdown-cjk-strong.e2e.ts

@@ -76,7 +76,7 @@ function markdownFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/markdown-images.e2e.ts

@@ -131,7 +131,7 @@ function markdownImageFixture(remoteUrl: string): string {
     // the stats line renders its LLM segment only while the step's measured
     // milliseconds exceed zero, so a fixture that leaves the times unset lets
     // the replay's own speed decide whether the golden matches.
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/markdown-inline-code-links.e2e.ts

@@ -73,7 +73,7 @@ function markdownFixture(linkUrl: string): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/markdown-wide-table.e2e.ts

@@ -139,7 +139,7 @@ function wideTableFixture(): string {
   return [
     JSON.stringify(header),
     // Spaced event times, as the sibling markdown fixtures pin them.
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/math-rendering.e2e.ts

@@ -77,7 +77,7 @@ function mathFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/minimal-preset.snapshot.ts

@@ -72,7 +72,7 @@ describe('minimal agent preset', () => {
   it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
     const requestHeader = agentHandle.agent.session.requestHeader()
     if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
-    expect(agentHandle.agent.session.events.some(event => event.type === 'user/message'
+    expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message'
       && event.data.source.kind === 'plugin'
       && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
     const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')

+ 1 - 1
apps/web/tests/produced-file-mentions.e2e.ts

@@ -107,7 +107,7 @@ function mentionFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       time: eventTimeOrigin + event.seq * 1_000,
     })),

+ 1 - 1
apps/web/tests/produced-files.e2e.ts

@@ -96,7 +96,7 @@ function producedFixture(): string {
       type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
       createdAt: 0, cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify({
+    ...session.snapshotEvents().map(event => JSON.stringify({
       ...event, time: eventTimeOrigin + event.seq * 1_000,
     })),
     '',

+ 2 - 2
apps/web/tests/reference-composer.e2e.ts

@@ -58,7 +58,7 @@ function sourceSessionFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify(event)),
+    ...session.snapshotEvents().map(event => JSON.stringify(event)),
     '',
   ].join('\n')
 }
@@ -105,7 +105,7 @@ function targetSessionFixture(): string {
       createdAt: 0,
       cwd: '{{cwd}}',
     }),
-    ...session.events.map(event => JSON.stringify(event)),
+    ...session.snapshotEvents().map(event => JSON.stringify(event)),
     '',
   ].join('\n')
 }

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

@@ -804,7 +804,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
 function rawSessionLog(session: Session): string {
   return [
     JSON.stringify({ type: 'session', ...session.header }),
-    ...packChunkRuns(session.events).map(record => JSON.stringify(record)),
+    ...packChunkRuns(session.snapshotEvents()).map(record => JSON.stringify(record)),
     '',
   ].join('\n')
 }
@@ -849,7 +849,7 @@ async function assertReplaySession(
   const userPrompts = fixtureUserPrompts(expected)
   const candidates = sessions.filter((session) => {
     if (session.header.parentSession !== undefined) return false
-    const actual = session.events.flatMap((event) => {
+    const actual = session.snapshotEvents().flatMap((event) => {
       if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
       const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
       return text.length === 0 ? [] : [text]

+ 8 - 8
apps/web/tests/schedule-after.e2e.ts

@@ -182,7 +182,7 @@ async function waitForReply(
 ): Promise<SessionEvent<'assistant/message'>> {
   const deadline = Date.now() + timeoutMs
   while (true) {
-    const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => (
+    const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => (
       candidate.type === 'assistant/message' && assistantText(candidate) === text
     ))
     if (event !== undefined) return event
@@ -403,7 +403,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
   it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every'))
     const ids = new Set(everyRecords.map(record => record.id))
-    const dispatches = everyHandle.agent.session.events.filter(event => (
+    const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => (
       event.type === 'schedule/change'
       && event.data.operation === 'dispatch'
       && ids.has(event.data.id)
@@ -418,7 +418,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
     const decision = acceptedAt[0]
     if (decision === undefined) throw new Error('missing Every decision time')
 
-    const batch = everyHandle.agent.session.events.find(event => (
+    const batch = everyHandle.agent.session.snapshotEvents().find(event => (
       event.type === 'user/message'
       && event.data.source.kind === 'plugin'
       && event.data.source.plugin === 'schedule'
@@ -441,7 +441,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
     if (reminderRequest === undefined) throw new Error('model did not receive the Every batch')
     expect(requestText(reminderRequest)).toContain(batchBlock.text)
     expectReminderFraming(reminderRequest)
-    const active = foldScheduleEvents(everyHandle.agent.session.events).active
+    const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active
     expect(active).toHaveLength(2)
     expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true)
 
@@ -463,7 +463,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
 
   it('uses request-local browser context to create an explicit local At reminder', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at'))
-    const user = atHandle.agent.session.events.find(event => (
+    const user = atHandle.agent.session.snapshotEvents().find(event => (
       event.type === 'user/message'
       && event.data.source.kind === 'user'
       && event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT)
@@ -488,12 +488,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
     }
     expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE)
 
-    const toolCall = atHandle.agent.session.events.find(event => (
+    const toolCall = atHandle.agent.session.snapshotEvents().find(event => (
       event.type === 'tool/call' && event.data.name === 'schedule_create'
     ))
     if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call')
     expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt })
-    const created = atHandle.agent.session.events.find(event => (
+    const created = atHandle.agent.session.snapshotEvents().find(event => (
       event.type === 'schedule/change'
       && event.data.operation === 'create'
       && event.data.schedule.kind === 'at'
@@ -507,7 +507,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
       prompt: AT_PROMPT,
       scheduledAt,
     })
-    expect(atHandle.agent.session.events.filter(event => (
+    expect(atHandle.agent.session.snapshotEvents().filter(event => (
       event.type === 'schedule/change'
       && event.data.operation === 'dispatch'
       && event.data.id === schedule.id

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -516,7 +516,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
       const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
       if (agent === undefined) throw new Error('seeded session did not attach an agent')
-      const done = agent.session.events.filter(event => event.type === 'command/done').at(-1)
+      const done = agent.session.snapshotEvents().filter(event => event.type === 'command/done').at(-1)
       if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
       const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
       expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)

+ 4 - 4
apps/web/tests/settings-chrome.e2e.ts

@@ -134,7 +134,7 @@ describe('web e2e: settings modal and General preferences', () => {
   it('stores Permission as the default for future sessions without changing an existing session', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
     const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
-    expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+    expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
       .toEqual({ preset: 'workspace-write' })
 
     await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -150,11 +150,11 @@ describe('web e2e: settings modal and General preferences', () => {
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('permission:')
     expect(document).toContain('defaultPreset: read-only')
-    expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+    expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data)
       .toEqual({ preset: 'workspace-write' })
 
     const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
-    expect(created.events.map(event => [event.type, event.data])).toEqual([
+    expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([
       ['permission/preset', { preset: 'read-only' }],
       ['sandbox/mode', { mode: 'read-only' }],
       ['approval/policy', { policy: 'ask' }],
@@ -171,7 +171,7 @@ describe('web e2e: settings modal and General preferences', () => {
     const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
     const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
-    expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
+    expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([
       ['permission/preset', { preset: 'danger-full-access' }],
       ['sandbox/mode', { mode: 'danger-full-access' }],
       ['approval/policy', { policy: 'never' }],

+ 1 - 1
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -290,7 +290,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     const child = scaffold.ctx.agents.get(childId)
     expect(child).toBeDefined()
     expect(child!.inbox.nextTurn).toHaveLength(2)
-    expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
+    expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2)
     await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
 
     // Only the waking send resumes the parked queue, FIFO, to settlement.

+ 2 - 2
apps/web/tests/subagent-interrupt.e2e.ts

@@ -158,8 +158,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th
     expect(child).toBeDefined()
     expect(child!.status).toBe('idle')
     expect(child!.inbox.nextTurn).toHaveLength(1)
-    expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
-    const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
+    expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1)
+    const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1)
     expect((lastEnd)?.data.reason.kind).toBe('aborted')
 
     // Only an explicit waking send resumes the parked queue, FIFO, then the

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: f3c246f7a77386a088f0559d233031bdb8d997f8
-session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75
+session.md: c6c2395ba2535b1d180c82e63dc31a1ed6c1e087
+session.zh.md: df2bac57a4a847df62467ed772386eca24c02183

+ 23 - 8
docs/subsystems/session.md

@@ -377,8 +377,15 @@ declare class Session {
    * When this lifecycle appends the marker, it occupies this seq before the
    * store attaches and therefore does not publish either. Otherwise this seq
    * holds an ordinary published write.
-   */
+  */
   readonly firstLiveSeq: number;
+  /**
+   * Whether construction received a replay, fork, or restored seed.
+   * Every seeded lifecycle has a `session/end-seed` marker after construction;
+   * this field distinguishes an explicitly empty seed from a fresh session
+   * without scanning the log.
+   */
+  readonly seeded: boolean;
   /**
    * Create a detached session by validating and snapshotting borrowed seed
    * events and storage metadata.
@@ -399,12 +406,20 @@ declare class Session {
    */
   static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;
   /**
-   * An immutable snapshot of the append-only event log. The snapshot is reused
-   * until the next append; a previously returned array does not grow later.
-   * Events and their nested data are deep-frozen at acceptance, so neither a
-   * cast nor ordinary JavaScript can rewrite durable history.
+   * Return the immutable event stored at one exact sequence number.
+   * @param seq - event sequence number.
+   * @returns the accepted event, or undefined when the log does not contain it.
+   */
+  eventAt(seq: number): SessionEvent | undefined;
+  /**
+   * Materialize an immutable snapshot of a half-open event sequence range.
+   * A full current snapshot is reused until the next append; every previously
+   * returned snapshot remains stable after later appends.
+   * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
+   * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
+   * @returns a frozen array of the selected deeply frozen events.
    */
-  get events(): readonly SessionEvent[];
+  snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];
   /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
   get seq(): number;
   /**
@@ -451,7 +466,7 @@ declare class Session {
    * The {@link EpochHeader} in force after the log's last header event — the
    * header the NEXT request will be compared against — or undefined before
    * the first `request/header` snapshot. The live, incrementally-maintained
-   * form of `foldRequestHeader(session.events)`: each header event is folded
+   * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded
    * once, when first seen, so a per-step read costs O(new events).
    * @returns the folded header, or undefined when no header event exists yet.
    */
@@ -573,7 +588,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h
 
 ## Durability contract
 
-What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
+What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
 
 The backends that consume this contract are on [persistence.md](persistence.md).
 

+ 23 - 8
docs/subsystems/session.zh.md

@@ -379,8 +379,15 @@ declare class Session {
    * When this lifecycle appends the marker, it occupies this seq before the
    * store attaches and therefore does not publish either. Otherwise this seq
    * holds an ordinary published write.
-   */
+  */
   readonly firstLiveSeq: number;
+  /**
+   * Whether construction received a replay, fork, or restored seed.
+   * Every seeded lifecycle has a `session/end-seed` marker after construction;
+   * this field distinguishes an explicitly empty seed from a fresh session
+   * without scanning the log.
+   */
+  readonly seeded: boolean;
   /**
    * Create a detached session by validating and snapshotting borrowed seed
    * events and storage metadata.
@@ -401,12 +408,20 @@ declare class Session {
    */
   static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;
   /**
-   * An immutable snapshot of the append-only event log. The snapshot is reused
-   * until the next append; a previously returned array does not grow later.
-   * Events and their nested data are deep-frozen at acceptance, so neither a
-   * cast nor ordinary JavaScript can rewrite durable history.
+   * Return the immutable event stored at one exact sequence number.
+   * @param seq - event sequence number.
+   * @returns the accepted event, or undefined when the log does not contain it.
+   */
+  eventAt(seq: number): SessionEvent | undefined;
+  /**
+   * Materialize an immutable snapshot of a half-open event sequence range.
+   * A full current snapshot is reused until the next append; every previously
+   * returned snapshot remains stable after later appends.
+   * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
+   * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
+   * @returns a frozen array of the selected deeply frozen events.
    */
-  get events(): readonly SessionEvent[];
+  snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];
   /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
   get seq(): number;
   /**
@@ -453,7 +468,7 @@ declare class Session {
    * The {@link EpochHeader} in force after the log's last header event — the
    * header the NEXT request will be compared against — or undefined before
    * the first `request/header` snapshot. The live, incrementally-maintained
-   * form of `foldRequestHeader(session.events)`: each header event is folded
+   * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded
    * once, when first seen, so a per-step read costs O(new events).
    * @returns the folded header, or undefined when no header event exists yet.
    */
@@ -577,7 +592,7 @@ interface TurnEndReasonMap {
 
 ## 持久性约定
 
-持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
+持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
 
 消费此约定的后端见 [persistence.md](persistence.zh.md)。
 

+ 1 - 1
packages/acp/acp/tests/approval.spec.ts

@@ -71,7 +71,7 @@ describe('ACP machine permission policy', () => {
     const foreign = {
       session: {
         id: request.agent.session.id,
-        events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }],
+        snapshotEvents: () => [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }],
         append: () => ({}),
       },
     } as unknown as Agent

+ 2 - 2
packages/acp/acp/tests/bridge.spec.ts

@@ -882,7 +882,7 @@ describe('automation-only ACP bridge', () => {
     expect(secondImage.attachment.mediaType).toBe('image/jpeg')
     expect(secondImage.attachment.bytes).toBe(1)
     const agent = harness.ctx.agents.get(SessionId(sessionId))
-    expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==')
+    expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==')
   })
 
   it('rejects a malformed image batch atomically and frees the prompt slot', async () => {
@@ -953,7 +953,7 @@ describe('automation-only ACP bridge', () => {
       sessionId,
       prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
     })).rejects.toThrow(/inline image prompts were not advertised/)
-    expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false)
+    expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false)
   })
 
   it('renders baseline resource links as textual references in the user message', async () => {

+ 5 - 5
packages/acp/acp/tests/turns.spec.ts

@@ -216,7 +216,7 @@ describe('ACP prompt lifecycle', () => {
     const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
       .finally(() => { settled = true })
     await vi.waitFor(() => {
-      expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced'
+      expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced'
         && event.data.inserted.length > 0)).toHaveLength(2)
     })
     expect(settled).toBe(false)
@@ -316,7 +316,7 @@ describe('ACP prompt lifecycle', () => {
 
     await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
     expect(harness.adapter.requests).toEqual([])
-    const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? []
+    const events = harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents() ?? []
     expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
   })
 
@@ -443,7 +443,7 @@ describe('ACP prompt lifecycle', () => {
     await harness.client.cancel({ sessionId })
     await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
     await agent.whenIdle()
-    expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
+    expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason)
       .toEqual({ kind: 'aborted', reason: { kind: 'user' } })
   })
 
@@ -468,13 +468,13 @@ describe('ACP prompt lifecycle', () => {
       source: { kind: 'plugin', plugin: 'test' },
     }))
     await vi.waitFor(() => {
-      expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
+      expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true)
     })
 
     await harness.client.cancel({ sessionId })
     await agent.whenIdle()
 
-    expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
+    expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason)
       .toEqual({ kind: 'aborted', reason: { kind: 'user' } })
   })
 

+ 2 - 2
packages/api/remotes/tests/built-lib.e2e.ts

@@ -197,8 +197,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
         scopedResult: scopedResult.value,
         rootGoal: host.goals.get(rootAgent)?.objective,
         scopedGoal: host.goals.get(scopedAgent)?.objective,
-        rootEvents: rootAgent.session.events.length,
-        scopedEvents: scopedAgent.session.events.length,
+        rootEvents: rootAgent.session.snapshotEvents().length,
+        scopedEvents: scopedAgent.session.snapshotEvents().length,
       }
 
       await client.fiber.dispose()

+ 1 - 1
packages/api/session-controller/src/commands.ts

@@ -484,7 +484,7 @@ export class SessionCommandController {
   private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
     const attached = this.ctx.sessions.get(sessionId)
     if (attached !== undefined) {
-      return { id: attached.id, header: attached.header, events: [...attached.events] }
+      return { id: attached.id, header: attached.header, events: [...attached.snapshotEvents()] }
     }
     const inspected = await inspectApiSession(this.ctx, sessionId)
     return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }

+ 1 - 1
packages/api/session-controller/src/history.ts

@@ -112,7 +112,7 @@ export class SessionHistoryController {
       // Constructor seed events have no session/event notification. Normally
       // only the end-seed suffix is new; if persistence advanced after the
       // opening observation, replay everything beyond that snapshot cursor.
-      const suffix = session.events.slice(snapshotCursor === undefined
+      const suffix = session.snapshotEvents(snapshotCursor === undefined
         ? session.firstLiveSeq
         : snapshotCursor + 1)
       buffered.unshift(...suffix)

+ 1 - 1
packages/api/session-controller/src/index.ts

@@ -194,7 +194,7 @@ export class SessionController extends TypertRemoteService {
   ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
     const attached = this.ctx.sessions.get(sessionId)
     if (attached !== undefined) {
-      return Promise.resolve({ meta: attached.header, events: [...attached.events] })
+      return Promise.resolve({ meta: attached.header, events: [...attached.snapshotEvents()] })
     }
     return inspectApiSession(this.ctx, sessionId, signal)
   }

+ 7 - 1
packages/api/session-controller/tests/agent.host.spec.ts

@@ -378,7 +378,13 @@ describe('ApiSession create or adoption', () => {
     } as never)
     const resumed = {
       id: meta.id,
-      session: { id: meta.id, header: meta, events },
+      session: {
+        id: meta.id,
+        header: meta,
+        snapshotEvents: () => events,
+        eventAt: (seq: number) => events[seq],
+        seq: events.length,
+      },
       status: 'idle',
       ctx,
     } as unknown as Agent

+ 1 - 1
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -326,7 +326,7 @@ describe('attached updatedAt tracks human prompts', () => {
       meta: { cwd: '/proj', createdAt: 500 },
     })
     ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
-    const boundary = resumed.events.at(-1)
+    const boundary = resumed.snapshotEvents().at(-1)
     expect(boundary?.type).toBe('session/end-seed')
     expect(boundary?.time).toBeGreaterThan(worked)
 

+ 6 - 6
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -92,7 +92,7 @@ describe('sessions.fork', () => {
     expect(response.ok).toBe(true)
     if (!response.ok) return
     const child = ctx.sessions.get(response.value.sessionId)
-    expect(child?.events.map(event => event.type)).toEqual([
+    expect(child?.snapshotEvents().map(event => event.type)).toEqual([
       'turn/start', 'user/message', 'turn/end', 'session/end-seed',
     ])
     expect(child?.header.parentSession).toBe(source.id)
@@ -198,13 +198,13 @@ describe('sessions.fork', () => {
     const omitted = await proxy.fork(request({ sessionId: source.id }))
     expect(omitted.ok).toBe(true)
     if (omitted.ok) {
-      expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type))
+      expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type))
         .toEqual(expectedTypes)
     }
     const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 }))
     expect(pastEnd.ok).toBe(true)
     if (pastEnd.ok) {
-      expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type))
+      expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type))
         .toEqual(expectedTypes)
     }
     await ctx.fiber.dispose()
@@ -227,11 +227,11 @@ describe('sessions.fork', () => {
     const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
     // What a stopped message's fork button anchors on: the frozen node sits
     // one event before its turn/end, floored client-side to that event's seq.
-    const anchor = (source.events.at(-1)?.seq ?? 0) - 1
+    const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response.ok).toBe(true)
     if (!response.ok) return
-    expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([
+    expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([
       'turn/start', 'user/message', 'turn/end',
       'turn/start', 'user/message', 'turn/end',
       'session/end-seed',
@@ -242,7 +242,7 @@ describe('sessions.fork', () => {
   it('rejects an in-log anchor whose turn is still open', async () => {
     const ctx = await composed()
     const source = liveAgent(ctx, 'session-open', 1, 'open')
-    const anchor = source.events.at(-1)?.seq ?? 0
+    const anchor = source.snapshotEvents().at(-1)?.seq ?? 0
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response).toMatchObject({
       ok: false,

+ 2 - 2
packages/api/session-controller/tests/session-history-journal.host.spec.ts

@@ -146,7 +146,7 @@ describe('Session history raw journal', () => {
       value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
     })
 
-    const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
+    const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => {
       throw new Error('live result rescanned Session history')
     })
     try {
@@ -366,7 +366,7 @@ describe('Session history raw journal', () => {
     await expect(iterator.next()).resolves.toMatchObject({
       value: { type: 'event', event: { type: 'turn/end' } },
     })
-    const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
+    const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => {
       throw new Error('live result rescanned Session history')
     })
     try {

+ 2 - 2
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -318,7 +318,7 @@ describe('session.history projections block', () => {
     expect('test/last-user' in after.projections.values).toBe(false)
     expect(after.projections.values.sessionListMetadata).toEqual({
       blank: true,
-      lastPromptAt: session.events.at(-1)?.time,
+      lastPromptAt: session.eventAt(session.seq - 1)?.time,
     })
   })
 
@@ -352,7 +352,7 @@ describe('session.list projections column', () => {
     expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
     expect(row?.projections?.values.sessionListMetadata).toEqual({
       blank: false,
-      lastPromptAt: session.events.at(-1)?.time,
+      lastPromptAt: session.eventAt(session.seq - 1)?.time,
     })
     expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
   })

+ 1 - 1
packages/api/session-controller/tests/session-rename.host.spec.ts

@@ -76,7 +76,7 @@ describe('sessions.rename', () => {
     expect(renamed.ok).toBe(true)
     if (!renamed.ok) return
     expect(renamed.value.title).toBe('new name')
-    const event = source.events.findLast(item => item.type === 'session/title')
+    const event = source.snapshotEvents().findLast(item => item.type === 'session/title')
     expect(event?.seq).toBe(renamed.value.seq)
     expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
   })

+ 16 - 11
packages/api/session-controller/tests/transport.host.spec.ts

@@ -33,6 +33,16 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent {
   } as SessionEvent
 }
 
+function eventSession(header: SessionHeader, events: readonly SessionEvent[]): Session {
+  return {
+    id: header.id,
+    header,
+    seq: events.length,
+    eventAt: (seq: number) => events[seq],
+    snapshotEvents: (fromSeq = 0, toSeqExclusive = events.length) => events.slice(fromSeq, toSeqExclusive),
+  } as unknown as Session
+}
+
 function cold(
   ctx: Context,
   header: SessionHeader,
@@ -163,12 +173,10 @@ describe('SessionHistoryController', () => {
       [Symbol.asyncIterator]()
     const opening = iterator.next()
 
-    ctx.emit('session/event', {
-      id: SessionId('unrelated'), events: [event('fixture/other', 0)],
-    } as unknown as Session, event('fixture/other', 0))
-    ctx.emit('session/event', {
-      id: sessionId, events: [event('fixture/start', 0)],
-    } as unknown as Session, event('fixture/start', 0))
+    const unrelated = event('fixture/other', 0)
+    const start = event('fixture/start', 0)
+    ctx.emit('session/event', eventSession({ ...header, id: SessionId('unrelated') }, [unrelated]), unrelated)
+    ctx.emit('session/event', eventSession(header, [start]), start)
     inspected.resolve({ meta: header, events: [event('fixture/start', 0)] })
     await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
 
@@ -194,7 +202,7 @@ describe('SessionHistoryController', () => {
     observed.resolve({
       source: 'live',
       header: attached.header,
-      events: attached.events,
+      events: attached.snapshotEvents(),
       cursor: attached.seq - 1,
       projections: { asOfSeq: attached.seq - 1, values: {} },
       retain: vi.fn(),
@@ -292,10 +300,7 @@ describe('SessionHistoryController', () => {
     await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
     const skipped = event('fixture/skipped', 1)
     const gap = event('fixture/gap', 2)
-    live.ctx.emit('session/event', {
-      id: session.id,
-      events: [event('fixture/start', 0), skipped, gap],
-    } as unknown as Session, gap)
+    live.ctx.emit('session/event', eventSession(session.header, [event('fixture/start', 0), skipped, gap]), gap)
     await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
   })
 

+ 9 - 5
packages/bundle/headless/src/index.ts

@@ -16,7 +16,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-default-model'
 import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 // Empty type imports carry the loader Context merge for the settlement await
 // and the cmdline Context merge for the appExit host value.
 import type {} from '@deepseek-ai/cordis-plugin-loader'
@@ -59,12 +59,16 @@ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stde
 }
 
 /** Aggregate the last assistant text and turn outcome in one owned interval. */
-function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
+function summarize(session: Session, firstSeq: number): RunOutcome {
   let started = false
   let text = ''
   let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
-  for (const event of events) {
-    if (event.seq < firstSeq) continue
+  const length = session.seq
+  for (let seq = firstSeq; seq < length; seq++) {
+    const event = session.eventAt(seq)
+    if (event === undefined) {
+      throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`)
+    }
     if (event.type === 'turn/start') {
       started = true
       continue
@@ -196,7 +200,7 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
     stopReasoning()
   }
   await sessions.flush(agent.session)
-  const outcome = summarize(agent.session.events, firstSeq)
+  const outcome = summarize(agent.session, firstSeq)
   io.stdout.write(outcome.text + '\n')
   if (outcome.reason?.kind === 'error') {
     io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)

+ 1 - 1
packages/compaction/command-compact/tests/command-compact.spec.ts

@@ -121,7 +121,7 @@ function expectLastLifecycle(
   args: string,
   outcome: CommandResult,
 ): string {
-  const lifecycle = test.agent.session.events
+  const lifecycle = test.agent.session.snapshotEvents()
     .filter(event => event.type === 'command/run' || event.type === 'command/done')
     .slice(-2)
   const runEvent = lifecycle[0]

+ 1 - 1
packages/compaction/command-compact/tests/loader-composition.spec.ts

@@ -130,7 +130,7 @@ describe('command-compact real Loader composition', () => {
       text: 'Compacted 3 history items (~99 tokens).',
       sourceEventSeq: RESULT.summarySeq,
     })
-    expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([
+    expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([
       {
         type: 'command/run',
         data: {

+ 6 - 7
packages/compaction/compaction-basic/src/region.ts

@@ -162,7 +162,7 @@ export async function compactSurfaceRegion(
 ): Promise<CompactionResult> {
   if (options.owner === null) signal?.throwIfAborted()
   const selection = validateSurfaceRegion(session, start, end)
-  const entryState = inspectCompactionEntryState(session.events)
+  const entryState = inspectCompactionEntryState(session)
   assertCompactionInactive(
     entryState.unmatchedCompactionStart,
     entryState.latestEndSeedSeq,
@@ -305,7 +305,7 @@ function assertCompactionInactive(
  * @param stage - operation label included in the busy diagnostic.
  */
 export function assertNoActiveCompaction(session: Session, stage: string): void {
-  const entryState = inspectCompactionEntryState(session.events)
+  const entryState = inspectCompactionEntryState(session)
   assertCompactionInactive(
     entryState.unmatchedCompactionStart,
     entryState.latestEndSeedSeq,
@@ -510,11 +510,10 @@ function buildSummarizationInput(
   shadowedSeqs: readonly number[],
 ): SummarizationInput {
   const header = session.requestHeader()
-  const events = session.events
   const regionMessages = shadowedSeqs
     // shadowedSeqs are current surface seqs, so each is a valid log index.
     // oxlint-disable-next-line typescript/no-non-null-assertion
-    .map(seq => session.deriveEventMessage(events[seq]!))
+    .map(seq => session.deriveEventMessage(session.eventAt(seq)!))
     .filter((message): message is Message => message !== null)
   return {
     ...header?.system === undefined ? {} : { system: header.system },
@@ -524,15 +523,15 @@ function buildSummarizationInput(
 }
 
 /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
-function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState {
+function inspectCompactionEntryState(session: Session): CompactionEntryState {
   let openTurn: number | null = null
   let openTurnStateKnown = false
   let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined
   let compactionEntryStateKnown = false
   let latestEndSeedSeq: number | undefined
-  for (let index = events.length - 1; index >= 0; index -= 1) {
+  for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
     // oxlint-disable-next-line typescript/no-non-null-assertion
-    const event = events[index]!
+    const event = session.eventAt(seq)!
     if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
       latestEndSeedSeq = event.seq
     }

+ 26 - 26
packages/compaction/compaction-basic/tests/compaction-basic.spec.ts

@@ -609,7 +609,7 @@ describe('pressure measurement and retention', () => {
 
     await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull()
     expect(session.surface.replaceGeneration).toBe(generation)
-    expect(session.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
   })
 
   it('does nothing below threshold and compacts a priced head above threshold', async () => {
@@ -837,10 +837,10 @@ describe('optional model-free tool-result pruning', () => {
 
     expect(await compactIfNeeded(compact, session)).not.toBeNull()
     expect(compact.calls).toHaveLength(1)
-    const original = session.events.find(event => event.type === 'tool/result')
+    const original = session.snapshotEvents().find(event => event.type === 'tool/result')
     expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
       .toEqual({ type: 'text', text: 'X'.repeat(3_000) })
-    expect(session.events.filter(event =>
+    expect(session.snapshotEvents().filter(event =>
       event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
   })
 })
@@ -866,7 +866,7 @@ describe('compaction region transaction', () => {
     expect(result.shadowedTokenCount).toBeGreaterThan(0)
     expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
     expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1')
-    const summary = session.events.findLast(event => event.type === 'compaction/summary')
+    const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary')
     expect(summary?.data).toMatchObject({
       shadowedSeqs: result.shadowedSeqs,
       shadowedTokenCount: result.shadowedTokenCount,
@@ -882,7 +882,7 @@ describe('compaction region transaction', () => {
     expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
     expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
 
-    const replay = Session.create(SessionId('replay'), [...session.events])
+    const replay = Session.create(SessionId('replay'), [...session.snapshotEvents()])
     expect(replay.deriveMessages()).toEqual(session.deriveMessages())
   })
 
@@ -1012,7 +1012,7 @@ describe('compaction region transaction', () => {
       agent(session, MODEL),
     )).rejects.toThrow('summary unavailable')
     expect(session.surface.nodes).toEqual(before)
-    expect(session.events.findLast(event => event.type === 'compaction/end')?.data)
+    expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
       .toMatchObject({ error: 'summary unavailable' })
   })
 
@@ -1026,7 +1026,7 @@ describe('compaction region transaction', () => {
       nodes[2]!,
       agent(session, MODEL),
     )).rejects.toBe('plain failure')
-    expect(session.events.findLast(event => event.type === 'compaction/end')?.data)
+    expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
       .toMatchObject({ error: 'plain failure' })
   })
 
@@ -1046,7 +1046,7 @@ describe('compaction region transaction', () => {
       nodes[2]!,
       agent(session, MODEL),
     )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) })
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
   })
 
   it('rejects concurrent surface appends before committing the replacement', async () => {
@@ -1065,7 +1065,7 @@ describe('compaction region transaction', () => {
       nodes[2]!,
       agent(session, MODEL),
     )).rejects.toThrow(/session surface changed/)
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
   })
 
   it('rejects a non-shrinking framed summary under the conversation meter', async () => {
@@ -1082,7 +1082,7 @@ describe('compaction region transaction', () => {
       nodes[2]!,
       agent(session, MODEL),
     )).rejects.toThrow(/summary is not smaller/)
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
   })
 
   it('lets a model-independent custom summarizer compact without a conversation model', async () => {
@@ -1333,7 +1333,7 @@ describe('default one-shot summarizer', () => {
     const session = conversation(3, 'large history '.repeat(500))
     const nodes = session.surface.nodes
     await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
-    expect(session.events.findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({
+    expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({
       summary: [{ type: 'text', text: 'routed summary' }],
       llmStreamCall: true,
       provider: 'routed-summary-provider',
@@ -1459,7 +1459,7 @@ describe('automatic listener and loader composition', () => {
     next: () => Promise<RequestErrorAction> = () => Promise.resolve(undefined),
   ): Promise<boolean> {
     const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
-    const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
+    const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1
     return agentEvents(ctx, owner).waterfall(
       'agent/request-error',
       { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
@@ -1479,11 +1479,11 @@ describe('automatic listener and loader composition', () => {
     })
     const pressured = conversation(4)
     await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
-    expect(pressured.events.some(event => event.type === 'compaction/summary')).toBe(true)
+    expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
 
     const small = conversation(1)
     await preStep(ctx, agent(small, MODEL))
-    expect(small.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
     expect(compact.calls).toHaveLength(1)
   })
 
@@ -1500,7 +1500,7 @@ describe('automatic listener and loader composition', () => {
       .resolves.toEqual({ kind: 'enter', messages: [] })
 
     expect(compactIfNeeded).not.toHaveBeenCalled()
-    expect(pressured.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
   })
 
   it('warns and continues after operational failures, including non-Errors', async () => {
@@ -1516,7 +1516,7 @@ describe('automatic listener and loader composition', () => {
 
     await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] })
     expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
   })
 
   it('warns once per routed target when proactive pressure has no context metadata', async () => {
@@ -1575,7 +1575,7 @@ describe('automatic listener and loader composition', () => {
 
     expect(decision).toBe(true)
     expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
     expect(session.surface.nodes).toContain(retainedSeq)
   })
 
@@ -1594,7 +1594,7 @@ describe('automatic listener and loader composition', () => {
 
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
     expect(session.surface.replaceGeneration).toBe(1)
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
     expect(compact.calls).toHaveLength(0)
   })
 
@@ -1612,7 +1612,7 @@ describe('automatic listener and loader composition', () => {
     const session = toolConversation()
 
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
     expect(compact.calls).toHaveLength(1)
     expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
   })
@@ -1635,8 +1635,8 @@ describe('automatic listener and loader composition', () => {
 
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
     expect(session.surface.replaceGeneration).toBe(1)
-    expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
-    expect(session.events.findLast(event => event.type === 'compaction/end')?.data)
+    expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2)
+    expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data)
       .toMatchObject({ error: 'summary unavailable after prune' })
     expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
   })
@@ -1838,10 +1838,10 @@ describe('automatic listener and loader composition', () => {
     })
     const session = conversation(4)
     await preStep(ctx, agent(session, MODEL))
-    const summaries = session.events.filter(event => event.type === 'compaction/summary').length
+    const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length
     expect(summaries).toBe(1)
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
-    expect(session.events.filter(event => event.type === 'compaction/summary')).toHaveLength(summaries)
+    expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries)
   })
 
   it('auto:false installs neither automatic listener', async () => {
@@ -1853,7 +1853,7 @@ describe('automatic listener and loader composition', () => {
     })
     const session = conversation(4)
     await preStep(ctx, agent(session, MODEL))
-    expect(session.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
   })
 
@@ -1885,7 +1885,7 @@ describe('automatic listener and loader composition', () => {
 
     const session = conversation(4)
     await preStep(ctx, agent(session, MODEL))
-    expect(session.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
     expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
   })
 })
@@ -2025,7 +2025,7 @@ describe('route-priced image pressure', () => {
 
     const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL)
     expect(result).not.toBeNull()
-    const summaryEvent = session.events.find(event => event.type === 'compaction/summary')
+    const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary')
     expect(summaryEvent).toBeDefined()
     const shadowedHeuristic = before.nodes
       .filter(node => result?.shadowedSeqs.includes(node.seq))

+ 9 - 9
packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts

@@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] {
     session.append('step/end', { turn, step: 1 })
     session.append('turn/end', { turn, reason: { kind: 'completed' } })
   }
-  return [...session.events]
+  return [...session.snapshotEvents()]
 }
 
 describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
@@ -233,8 +233,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
       await waitForIdle(ctx, agent)
 
       expect(agent.session.requestHeader()?.config.model).toBe('mock')
-      expect(agent.session.events.some(event => event.type === 'compaction/summary')).toBe(true)
-      expect(agent.session.events.at(-1)).toMatchObject({
+      expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
+      expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
         type: 'turn/end',
         data: { reason: { kind: 'completed' } },
       })
@@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
       agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } }))
       await waitForIdle(ctx, agent)
 
-      const events = [...agent.session.events]
+      const events = [...agent.session.snapshotEvents()]
       const compactStart = events.find(event => event.type === 'compaction/start')
       expect(compactStart).toBeDefined()
       const precedingResult = events.findLast(event =>
@@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
       agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } }))
       await waitForIdle(ctx, agent)
 
-      const events = [...agent.session.events]
+      const events = [...agent.session.snapshotEvents()]
       // A compaction ran: at least one checkpoint landed on the surface.
       const checkpoints = events.filter(
         (e): e is SurfaceEvent =>
@@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic',
         expect(retry).toContain('RECOVERY CHECKPOINT')
         expect(retry).not.toContain('OLD HISTORY SENTINEL')
 
-        const events = [...agent.session.events]
+        const events = [...agent.session.snapshotEvents()]
         const stepStart = events.find(event =>
           event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1,
         )!
@@ -419,11 +419,11 @@ describe('context-overflow recovery across the real loop and compaction-basic',
 
       expect(adapter.conversationRequests).toHaveLength(3)
       expect(adapter.summaryRequests).toHaveLength(1)
-      expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
+      expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data))
         .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
-      expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
+      expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
         .toEqual([3])
-      expect(agent.session.events.at(-1)).toMatchObject({
+      expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
         type: 'turn/end',
         data: { reason: { kind: 'completed' } },
       })

+ 30 - 30
packages/compaction/compaction-basic/tests/manual-compaction.spec.ts

@@ -234,8 +234,8 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus
   return { ctx, compact: new GatedCompactionEngine(ctx, { auto: false }), flushes: () => flushes }
 }
 
-function compactEvents(session: Session): Array<Session['events'][number]> {
-  return session.events.filter(event => event.type.startsWith('compaction/'))
+function compactEvents(session: Session): SessionEvent[] {
+  return session.snapshotEvents().filter(event => event.type.startsWith('compaction/'))
 }
 
 describe('compactNow through the real loop', () => {
@@ -296,14 +296,14 @@ describe('compactNow through the real loop', () => {
     const result = await compact.compactNow(agent, SIGNAL)
 
     expect(result).not.toBeNull()
-    const start = agent.session.events.findLast(event => event.type === 'compaction/start')
+    const start = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/start')
     const injected = agent.inbox.nextStep.find(message =>
       message.source.kind === 'plugin' && message.source.plugin === 'test')
-    const end = agent.session.events.findLast(event => event.type === 'compaction/end')
+    const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end')
     expect(start).toBeDefined()
     expect(injected).toBeDefined()
     expect(end).toBeDefined()
-    expect(agent.session.events.some(event => event.type === 'user/message'
+    expect(agent.session.snapshotEvents().some(event => event.type === 'user/message'
       && event.data.id === injected?.id)).toBe(false)
 
     agent.followup(createUserMessage({
@@ -335,7 +335,7 @@ describe('compactNow through the real loop', () => {
     expect(attempts).toEqual(['compaction/start', 'compaction/summary'])
     expect(result).not.toBeNull()
     expect(derivedText(agent.session)[0]).toContain('checkpoint')
-    expect(agent.session.events.filter(event => event.type === 'user/message'
+    expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message'
       && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
     const types = compactEvents(agent.session).map(event => event.type)
     expect(types).toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
@@ -355,7 +355,7 @@ describe('compactNow through the real loop', () => {
 
     await agent.whenIdle()
     expect(adapter.requests).toHaveLength(2)
-    expect(agent.session.events.some(event => event.type === 'compaction/start')).toBe(false)
+    expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
   })
 
   it('releases turn admission after a summarizer failure and records the failed attempt', async () => {
@@ -405,14 +405,14 @@ describe('compactNow transaction and failure classification', () => {
     expect(result).not.toBeNull()
     expect(result?.sourceCommandId).toBe(commandId)
     expect(flushes()).toBe(1)
-    expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
-    const start = session.events.findLast(event => event.type === 'compaction/start')
-    const summaryEvent = session.events.findLast(event => event.type === 'compaction/summary')
-    const checkpoint = session.events.findLast(
+    expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
+    const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start')
+    const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary')
+    const checkpoint = session.snapshotEvents().findLast(
       (event): event is SessionEvent<'user/message'> => event.type === 'user/message'
         && isCompactCheckpointSource(event.data.source),
     )
-    const end = session.events.findLast(event => event.type === 'compaction/end')
+    const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end')
     const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId }
     expect(start?.data).toEqual({ ...correlated, turn: null })
     expect(summaryEvent?.data.sourceCommandId).toBe(commandId)
@@ -442,9 +442,9 @@ describe('compactNow transaction and failure classification', () => {
       compactionId: CompactionId('stale-manual-compaction'),
       turn: null,
     })
-    const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
-    const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
-    const orphan = reloaded.events.find(event => event.type === 'compaction/start')
+    const reloaded = Session.create(SessionId('stale-orphan'), [...original.snapshotEvents()])
+    const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed')
+    const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start')
     const agent = fakeAgent(reloaded, () => () => undefined)
 
     expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER)
@@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => {
     })
     original.append('turn/start', { turn: 3 })
     original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
-    const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
+    const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.snapshotEvents()])
     const agent = fakeAgent(reloaded, () => () => undefined)
 
     await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
@@ -564,7 +564,7 @@ describe('compactNow transaction and failure classification', () => {
     expect(session.surface.replaceGeneration).toBe(generation + 1)
     expect(session.surface.nodes).not.toContain(head)
     expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end'])
-    expect(session.events.some(event => event.type === 'user/message'
+    expect(session.snapshotEvents().some(event => event.type === 'user/message'
       && isCompactCheckpointSource(event.data.source))).toBe(false)
   })
 
@@ -583,7 +583,7 @@ describe('compactNow transaction and failure classification', () => {
     expect(causeOf(error).message).toBe('boundary rejected')
     vi.restoreAllMocks()
     expect(flushes()).toBe(0)
-    expect(session.events.findLast(event => event.type.startsWith('compaction/'))?.type)
+    expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type)
       .toBe('compaction/summary')
     expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1)
 
@@ -649,7 +649,7 @@ describe('compactNow transaction and failure classification', () => {
     vi.restoreAllMocks()
     expect(error.code).toBe('commit')
     expect(released).toBe(1)
-    const end = session.events.findLast(event => event.type === 'compaction/end')
+    const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end')
     expect(end?.type === 'compaction/end' && end.data.error).toContain('summary record rejected')
     expect(end?.type === 'compaction/end' && end.data.turn).toBeNull()
   })
@@ -685,8 +685,8 @@ describe('compactNow transaction and failure classification', () => {
     const result = await compact.compactNow(agent, SIGNAL)
 
     expect(result).not.toBeNull()
-    expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
-    expect(session.events.find(event => event.type === 'compaction/start')?.data)
+    expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false)
+    expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data)
       .toEqual({ compactionId: result?.compactionId, turn: null })
   })
 
@@ -698,9 +698,9 @@ describe('compactNow transaction and failure classification', () => {
 
     expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
     vi.restoreAllMocks()
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true)
-    const start = session.events.findLast(event => event.type === 'compaction/start')
-    const end = session.events.findLast(event => event.type === 'compaction/end')
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
+    const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start')
+    const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end')
     expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null })
   })
 
@@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => {
       const reserve = vi.fn(() => testCase.release)
       const measure = vi.spyOn(ctx.tokenMeter, 'measure')
       const agent = fakeAgent(testCase.session, reserve)
-      const before = [...testCase.session.events]
+      const before = [...testCase.session.snapshotEvents()]
       const reason = Object.freeze({ kind: 'cancelled', case: testCase.name })
       const controller = new AbortController()
       controller.abort(reason)
@@ -731,7 +731,7 @@ describe('compactNow transaction and failure classification', () => {
       expect(reserve).not.toHaveBeenCalled()
       expect(measure).not.toHaveBeenCalled()
       expect(compact.calls).toHaveLength(0)
-      expect(testCase.session.events).toEqual(before)
+      expect(testCase.session.snapshotEvents()).toEqual(before)
       vi.restoreAllMocks()
     }
   })
@@ -780,7 +780,7 @@ describe('compactNow transaction and failure classification', () => {
 
     await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
     expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end'])
-    expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false)
+    expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
   })
 
   it('waits for the durability checkpoint before cancellation wins and admission releases', async () => {
@@ -824,7 +824,7 @@ describe('compactNow transaction and failure classification', () => {
 
     await compact.compactNow(agent, SIGNAL)
 
-    const summary = session.events.find(event => event.type === 'compaction/summary')
+    const summary = session.snapshotEvents().find(event => event.type === 'compaction/summary')
     expect(summary?.type === 'compaction/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
     expect(summary?.type === 'compaction/summary' && summary.data.usage).toEqual(compact.usage)
   })
@@ -839,8 +839,8 @@ describe('compactNow transaction and failure classification', () => {
 
     await compact.compactNow(agent, SIGNAL)
 
-    const start = session.events.findLast(event => event.type === 'compaction/start')
-    const end = session.events.findLast(event => event.type === 'compaction/end')
+    const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start')
+    const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end')
     expect(start).toBeDefined()
     expect(end).toBeDefined()
     expect(end!.time - start!.time).toBeGreaterThan(0)

+ 1 - 1
packages/compaction/compaction-tool-result-pruner/src/index.ts

@@ -136,7 +136,7 @@ export class ToolResultPruner extends Service {
   pruneSession(session: Session): PruneResult {
     const candidates: SnapshotCandidate[] = []
     for (const seq of [...session.surface.nodes]) {
-      const event = session.events[seq]
+      const event = session.eventAt(seq)
       /* v8 ignore next -- surface seqs are validated contiguous log references. */
       if (event?.type === 'tool/result') candidates.push({ seq, event })
     }

+ 4 - 4
packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts

@@ -185,8 +185,8 @@ describe('ToolResultPruner session transaction', () => {
     expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 })
     expect(entry.charsAfter).toBeLessThanOrEqual(50)
 
-    const original = session.events[originalSeq]!
-    const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
+    const original = session.snapshotEvents()[originalSeq]!
+    const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent
     expect(original).toMatchObject({
       type: 'tool/result',
       data: {
@@ -219,7 +219,7 @@ describe('ToolResultPruner session transaction', () => {
     // Shadow-price protocol: the metering event sits directly before the
     // replacement and prices the shadowed node with the shared estimator.
     if (original.type !== 'tool/result') throw new Error('original is not a tool/result')
-    expect(session.events[entry.replacementSeq - 1]).toMatchObject({
+    expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({
       type: 'compaction/prune',
       data: {
         shadowedRange: { start: originalSeq, end: originalSeq },
@@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => {
       turn: 2,
     })
     service().pruneSession(session)
-    const replay = Session.create(session.id, [...session.events])
+    const replay = Session.create(session.id, [...session.snapshotEvents()])
     expect(replay.deriveMessages()).toEqual(session.deriveMessages())
     expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
   })

+ 3 - 2
packages/compaction/compaction/src/invariant.ts

@@ -251,8 +251,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
   const seed = (session: Session): SessionTrace => {
     const trace: SessionTrace = { openTurn: null, compaction: undefined }
     traces.set(session, trace)
-    const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events)
-    for (const event of session.events) {
+    const events = session.snapshotEvents()
+    const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events)
+    for (const event of events) {
       // Constructor-seed repair boundaries can precede the end-seed marker
       // that proves an inherited orphan stale. Replay that inherited prefix
       // without letting the soon-to-be-cleared bracket veto its repair.

+ 5 - 11
packages/compaction/compaction/src/tool-pairing.ts

@@ -37,15 +37,6 @@ function eventDelta(event: SessionEvent): number {
   }
 }
 
-/** Read and validate the event named by a surface sequence. */
-function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent {
-  const event = events[seq]
-  if (event === undefined || event.seq !== seq) {
-    throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
-  }
-  return event
-}
-
 /** Fold surface sequences not yet in the cache into its balance state. */
 function extendCache(
   session: Session,
@@ -56,11 +47,14 @@ function extendCache(
   const tail = seqs.slice(processed)
   // Validate the unseen tail before mutating the live cache, so a corrupt
   // append cannot leave a partially advanced state behind.
-  const events = session.events
   const pendingCuts: boolean[] = []
   let inProgressToolCalls = cache.inProgressToolCalls
   for (const seq of tail) {
-    inProgressToolCalls += eventDelta(eventForSeq(events, seq))
+    const event = session.eventAt(seq)
+    if (event === undefined || event.seq !== seq) {
+      throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
+    }
+    inProgressToolCalls += eventDelta(event)
     if (inProgressToolCalls < 0) {
       throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
     }

+ 3 - 3
packages/compaction/compaction/tests/compaction.spec.ts

@@ -130,7 +130,7 @@ describe('CompactionEngine seam', () => {
 
     const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'))
 
-    const startEvent = session.events.find(e => e.type === 'compaction/start')
+    const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start')
     expect(startEvent).toBeDefined()
     // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType);
     // verify the runtime value is absent.
@@ -141,13 +141,13 @@ describe('CompactionEngine seam', () => {
     expect(result.endSeq).toBeGreaterThan(result.summarySeq)
     expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq })
     expect(result.shadowedSeqs).toEqual([original.seq])
-    const checkpoint = session.events.find(event => event.type === 'user/message'
+    const checkpoint = session.snapshotEvents().find(event => event.type === 'user/message'
       && isCompactCheckpointSource(event.data.source))
     expect(checkpoint?.type === 'user/message' && checkpoint.data.source)
       .toEqual(compactCheckpointSource(result.compactionId))
     expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false)
     expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false)
-    expect(session.events.filter(e => e.type.startsWith('compaction/')).map(e => e.type))
+    expect(session.snapshotEvents().filter(e => e.type.startsWith('compaction/')).map(e => e.type))
       .toEqual(['compaction/start', 'compaction/summary', 'compaction/end'])
   })
 

+ 8 - 8
packages/compaction/compaction/tests/invariant.spec.ts

@@ -68,9 +68,9 @@ describe('compaction invariants', () => {
     const source = Session.create(SessionId('stale-compaction-source'))
     source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null })
     const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
-      seed: source.events,
+      seed: source.snapshotEvents(),
     })
-    expect(replayed.events.map(event => event.type))
+    expect(replayed.snapshotEvents().map(event => event.type))
       .toEqual(['compaction/start', 'session/end-seed'])
 
     await ctx.plugin(InvariantRegistry)
@@ -89,9 +89,9 @@ describe('compaction invariants', () => {
     startTurn(source)
     source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })
     const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
-      seed: source.events,
+      seed: source.snapshotEvents(),
     })
-    expect(replayed.events.map(event => event.type))
+    expect(replayed.snapshotEvents().map(event => event.type))
       .toEqual(['turn/start', 'compaction/start', 'session/end-seed'])
 
     await ctx.plugin(InvariantRegistry)
@@ -111,9 +111,9 @@ describe('compaction invariants', () => {
     startTurn(source)
     source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
     const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
-      seed: source.events,
+      seed: source.snapshotEvents(),
     })
-    expect(replayed.events.map(event => event.type)).toEqual([
+    expect(replayed.snapshotEvents().map(event => event.type)).toEqual([
       'compaction/start',
       'turn/start',
       'turn/end',
@@ -138,9 +138,9 @@ describe('compaction invariants', () => {
     source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
     source.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' })
     const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
-      seed: source.events,
+      seed: source.snapshotEvents(),
     })
-    expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
+    expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed')
 
     await ctx.plugin(InvariantRegistry)
     await expect(ctx.plugin(CompactionInvariant).then(() => undefined))

+ 16 - 29
packages/compaction/compaction/tests/tool-pairing.spec.ts

@@ -7,7 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 const SURFACE = { surfaceOp: 'append' as const }
 
 function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
-  return session.events.filter(event => event.type === type)[nth]!.seq
+  return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq
 }
 
 function surfaceSeq(session: Session, seq: number): number {
@@ -241,41 +241,31 @@ describe('tool-pairing cache refresh', () => {
     ]
     const nodes: number[] = [0, 1, 2]
     let generation = 0
-    let eventCollectionReads = 0
-    let eventIndexReads = 0
-    const trackedEvents = new Proxy(events, {
-      get(target, property, receiver) {
-        if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
-        return Reflect.get(target, property, receiver) as unknown
-      },
-    })
+    let eventReads = 0
     const surface = {
       get nodes() { return nodes },
       get replaceGeneration() { return generation },
     }
     const session = {
       surface,
-      get events() {
-        eventCollectionReads += 1
-        return trackedEvents
+      eventAt(seq: number) {
+        eventReads += 1
+        return events[seq]
       },
     } as unknown as Session
 
     expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
-    expect(eventCollectionReads).toBe(1)
-    expect(eventIndexReads).toBe(3)
+    expect(eventReads).toBe(3)
 
     expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
     expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
-    expect(eventCollectionReads).toBe(1)
-    expect(eventIndexReads).toBe(3)
+    expect(eventReads).toBe(3)
 
     events.push({
       type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } },
     })
     expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
-    expect(eventCollectionReads).toBe(1)
-    expect(eventIndexReads).toBe(3)
+    expect(eventReads).toBe(3)
 
     events.push({
       type: 'user/message', seq: 4, time: 4,
@@ -286,8 +276,7 @@ describe('tool-pairing cache refresh', () => {
     })
     nodes.push(4)
     expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
-    expect(eventCollectionReads).toBe(2)
-    expect(eventIndexReads).toBe(4)
+    expect(eventReads).toBe(4)
 
     events.push(
       {
@@ -321,8 +310,7 @@ describe('tool-pairing cache refresh', () => {
     )
     nodes.push(5, 6)
     expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
-    expect(eventCollectionReads).toBe(3)
-    expect(eventIndexReads).toBe(6)
+    expect(eventReads).toBe(6)
 
     events.push({
       type: 'user/message', seq: 7, time: 7,
@@ -334,8 +322,7 @@ describe('tool-pairing cache refresh', () => {
     nodes.splice(0, nodes.length, 7)
     generation += 1
     expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
-    expect(eventCollectionReads).toBe(4)
-    expect(eventIndexReads).toBe(7)
+    expect(eventReads).toBe(7)
   })
 
   it('rebuilds defensively when a same-generation surface entry count regresses', () => {
@@ -355,7 +342,7 @@ describe('tool-pairing cache refresh', () => {
     ]
     const nodes: number[] = [0, 1]
     const session = {
-      events,
+      eventAt: (seq: number) => events[seq],
       surface: { nodes, replaceGeneration: 0 },
     } as unknown as Session
     expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
@@ -399,24 +386,24 @@ describe('tool-pairing corrupt surfaces', () => {
   it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
     const missingSeq = 1
     const missing = {
-      events: [{
+      eventAt: (seq: number) => [{
         type: 'user/message', seq: 0, time: 0,
         data: createUserMessage({
           content: [], source: { kind: 'user' },
         }), surfaceOp: 'append',
-      } satisfies SessionEvent],
+      } satisfies SessionEvent][seq],
       surface: { nodes: [missingSeq], replaceGeneration: 0 },
     } as unknown as Session
     expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
 
     const mismatchedSeq = 0
     const mismatched = {
-      events: [{
+      eventAt: (seq: number) => [{
         type: 'user/message', seq: 99, time: 0,
         data: createUserMessage({
           content: [], source: { kind: 'user' },
         }), surfaceOp: 'append',
-      } satisfies SessionEvent],
+      } satisfies SessionEvent][seq],
       surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
     } as unknown as Session
     expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)

+ 2 - 2
packages/context/agent-instructions/src/index.ts

@@ -53,7 +53,7 @@ function visibleBaselineSource(
     }
   }
   for (const seq of agent.session.surface.nodes.toReversed()) {
-    const event = agent.session.events[seq]
+    const event = agent.session.eventAt(seq)
     if (event?.type === 'user/message'
       && event.data.source.kind === 'agent-instructions'
       && event.data.source.baseline === true) return event.data.source
@@ -228,7 +228,7 @@ export function apply(ctx: Context, config: Config): void {
     const alreadySupplied = desired !== undefined && (
       claimed.some(message => sameContextPayload(message, desired))
       || agent.session.surface.nodes.some((seq) => {
-        const event = agent.session.events[seq]
+        const event = agent.session.eventAt(seq)
         return event?.type === 'user/message' && sameContextPayload(event.data, desired)
       })
     )

+ 1 - 1
packages/context/agent-instructions/src/state.ts

@@ -139,7 +139,7 @@ function visibleInstructionChanges(
 ): Map<string, AgentInstructionChange> {
   const visibleSeqs = new Set(agent.session.surface.nodes)
   const visible = new Map<string, AgentInstructionChange>()
-  for (const [seq, event] of agent.session.events.entries()) {
+  for (const [seq, event] of agent.session.snapshotEvents().entries()) {
     if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
     const changes = workspaceInstructionChanges(event.data.source)
     for (const change of changes) {

+ 3 - 3
packages/context/agent-instructions/tests/agent-instructions.e2e.ts

@@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
     live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } }))
     await waitForIdle(live.ctx, live.agent)
 
-    expect(finalText([...live.agent.session.events])).toContain(PROBE)
+    expect(finalText([...live.agent.session.snapshotEvents()])).toContain(PROBE)
   }, 120_000)
 
   it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => {
@@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
     live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } }))
     await waitForIdle(live.ctx, live.agent)
 
-    expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
+    expect(finalText([...live.agent.session.snapshotEvents()])).toContain(NESTED_PROBE)
   }, 120_000)
 
   it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
@@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
     live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } }))
     await waitForIdle(live.ctx, live.agent)
 
-    const events = [...live.agent.session.events]
+    const events = [...live.agent.session.snapshotEvents()]
     const update = events.find(event => event.type === 'user/message'
       && event.data.source.kind === 'agent-instructions'
       && event.data.source.baseline !== true)

+ 34 - 34
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -246,7 +246,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise<UserM
 }
 
 function baselineEvents(agent: Agent): SessionEvent[] {
-  return agent.session.events.filter(event =>
+  return agent.session.snapshotEvents().filter(event =>
     event.type === 'user/message'
     && event.data.source.kind === 'agent-instructions'
     && event.data.source.baseline === true)
@@ -1096,7 +1096,7 @@ describe('workspace context request injection', () => {
       const second = await composeBaselinePrefix(ctx, agent)
 
       expect(second).toEqual(first)
-      expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
+      expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
       expect(derivedText(agent)).toContain('repo rule')
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -1115,14 +1115,14 @@ describe('workspace context request injection', () => {
       const original = stubAgent(root)
       await composeBaselinePrefix(ctx, original)
 
-      const firstResume = stubAgent(root, [...original.session.events])
+      const firstResume = stubAgent(root, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, firstResume)
-      const secondResume = stubAgent(root, [...firstResume.session.events])
+      const secondResume = stubAgent(root, [...firstResume.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, secondResume)
 
       expect(baselineEvents(firstResume)).toHaveLength(1)
       expect(baselineEvents(secondResume)).toHaveLength(1)
-      expect(secondResume.session.events.filter(event => event.type === 'user/message'
+      expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions')).toHaveLength(1)
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -1144,11 +1144,11 @@ describe('workspace context request injection', () => {
       await composeBaselinePrefix(ctx, original)
 
       fs.throwOnStat.add(join(root, 'AGENTS.md'))
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, resumed)
 
       expect(baselineEvents(resumed)).toHaveLength(1)
-      expect(resumed.session.events.filter(event => event.type === 'user/message'
+      expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions')).toHaveLength(1)
     } finally {
       await ctx.fiber.dispose()
@@ -1170,13 +1170,13 @@ describe('workspace context request injection', () => {
       const original = stubAgent(cwd)
       await composeBaselinePrefix(ctx, original)
 
-      const firstResume = stubAgent(cwd, [...original.session.events])
+      const firstResume = stubAgent(cwd, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, firstResume)
-      const secondResume = stubAgent(cwd, [...firstResume.session.events])
+      const secondResume = stubAgent(cwd, [...firstResume.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, secondResume)
 
       expect(baselineEvents(secondResume)).toHaveLength(1)
-      expect(secondResume.session.events.filter(event => event.type === 'user/message'
+      expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions')).toHaveLength(1)
       expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md')
       expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root')
@@ -1200,11 +1200,11 @@ describe('workspace context request injection', () => {
       await composeBaselinePrefix(ctx, original)
 
       await write(join(cwd, 'AGENTS.md'), 'package rule')
-      const resumed = stubAgent(cwd, [...original.session.events])
+      const resumed = stubAgent(cwd, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(ctx, resumed)
 
       expect(baselineEvents(resumed)).toHaveLength(1)
-      const update = resumed.session.events.findLast(event => event.type === 'user/message'
+      const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions'
         && event.data.source.baseline !== true)
       expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions'
@@ -1237,7 +1237,7 @@ describe('workspace context request injection', () => {
         maxBytes: 65536,
         instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
       })
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(resumedCtx, resumed)
 
       const baselines = baselineEvents(resumed)
@@ -1256,7 +1256,7 @@ describe('workspace context request injection', () => {
         : [])
       expect(new Set(baselineIdentities).size).toBe(2)
 
-      const repeated = stubAgent(root, [...resumed.session.events])
+      const repeated = stubAgent(root, [...resumed.session.snapshotEvents()])
       await composeBaselinePrefix(resumedCtx, repeated)
       expect(baselineEvents(repeated)).toHaveLength(2)
     } finally {
@@ -1290,7 +1290,7 @@ describe('workspace context request injection', () => {
         maxBytes: 65536,
         instructionFileCandidates: ['CLAUDE.md'],
       })
-      const claudeResume = stubAgent(root, [...original.session.events])
+      const claudeResume = stubAgent(root, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(claudeCtx, claudeResume)
       const claudeBaseline = baselineEvents(claudeResume).at(-1)
       expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions'
@@ -1305,7 +1305,7 @@ describe('workspace context request injection', () => {
         maxBytes: 65536,
         instructionFileCandidates: ['AGENTS.md'],
       })
-      const restored = stubAgent(root, [...claudeResume.session.events])
+      const restored = stubAgent(root, [...claudeResume.session.snapshotEvents()])
       await composeBaselinePrefix(restoredCtx, restored)
       const restoredBaseline = baselineEvents(restored).at(-1)
       expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions'
@@ -1340,7 +1340,7 @@ describe('workspace context request injection', () => {
         maxBytes: 65536,
         instructionFileCandidates: ['POLICY.md'],
       })
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       await composeBaselinePrefix(resumedCtx, resumed)
 
       const baselines = baselineEvents(resumed)
@@ -1354,7 +1354,7 @@ describe('workspace context request injection', () => {
         { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
       ])
 
-      const repeated = stubAgent(root, [...resumed.session.events])
+      const repeated = stubAgent(root, [...resumed.session.snapshotEvents()])
       await composeBaselinePrefix(resumedCtx, repeated)
       expect(baselineEvents(repeated)).toHaveLength(2)
     } finally {
@@ -1384,7 +1384,7 @@ describe('workspace context request injection', () => {
 
       await fiber.dispose()
       await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
       const claimed = resumed.inbox.claim('next-step', 1)
       const decision = await agentEvents(ctx, resumed).waterfall(
@@ -1400,7 +1400,7 @@ describe('workspace context request injection', () => {
 
       expect(decision.messages.map(message => message.id)).toEqual([inserted?.id])
       expect(resumed.inbox.nextStep).toEqual([])
-      expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced'
+      expect(resumed.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced'
         && event.data.inserted.some(message => message.source.kind === 'agent-instructions'
           && message.source.baseline === true))).toHaveLength(1)
       expect(baselineEvents(resumed)).toHaveLength(1)
@@ -1430,7 +1430,7 @@ describe('workspace context request injection', () => {
       await write(join(root, 'AGENTS.md'), 'new repo rule')
       await fiber.dispose()
       await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
       const staleClaim = resumed.inbox.claim('next-step', 1)
       const staleDecision = await agentEvents(ctx, resumed).waterfall(
@@ -1483,7 +1483,7 @@ describe('workspace context request injection', () => {
       await originalCtx.fiber.dispose()
       if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
       await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes })
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
       agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
       const claimed = resumed.inbox.claim('next-step', 1)
       const decision = await agentEvents(resumedCtx, resumed).waterfall(
@@ -1524,7 +1524,7 @@ describe('workspace context request injection', () => {
 
       await composeBaselinePrefix(ctx, agent)
 
-      const removal = agent.session.events.find(event => event.type === 'user/message'
+      const removal = agent.session.snapshotEvents().find(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions'
         && event.data.source.changes.some(change => change.action === 'remove'))
       expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({
@@ -1558,7 +1558,7 @@ describe('workspace context request injection', () => {
 
       await composeBaselinePrefix(ctx, agent)
 
-      const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message'
+      const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions')
       expect(workspaceEvents).toHaveLength(2)
       expect(workspaceEvents.some(event => event.type === 'user/message'
@@ -1567,7 +1567,7 @@ describe('workspace context request injection', () => {
       expect(baselineEvents(agent)).toHaveLength(1)
 
       await composeBaselinePrefix(ctx, agent)
-      expect(agent.session.events.filter(event => event.type === 'user/message'
+      expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message'
         && event.data.source.kind === 'agent-instructions')).toHaveLength(2)
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -1790,7 +1790,7 @@ describe('workspace context request injection', () => {
       // The first resumed pre-step retains the compatible visible baseline and
       // appends only the offline file transition needed to reach current state.
       await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
 
       // Resume announces its lifecycle start before the first step.
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
@@ -1798,7 +1798,7 @@ describe('workspace context request injection', () => {
 
       const baselines = baselineEvents(resumed)
       expect(baselines).toHaveLength(1)
-      const latest = resumed.session.events.findLast(event =>
+      const latest = resumed.session.snapshotEvents().findLast(event =>
         event.type === 'user/message' && event.data.source.kind === 'agent-instructions')
       expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({
         changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
@@ -1979,7 +1979,7 @@ describe('workspace context request injection', () => {
 
       await composeBaselinePrefix(ctx, agent)
 
-      const contexts = agent.session.events.filter(event =>
+      const contexts = agent.session.snapshotEvents().filter(event =>
         event.type === 'user/message' && event.data.source.kind !== 'user',
       )
       expect(contexts).toHaveLength(1)
@@ -2558,14 +2558,14 @@ describe('dynamic nested workspace context injection', () => {
 
       agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } }))
       await agent.whenIdle()
-      expect(agent.session.events.filter(event =>
+      expect(agent.session.snapshotEvents().filter(event =>
         event.type === 'user/message' && event.data.source.kind !== 'user',
       )).toHaveLength(0)
 
       agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } }))
       await agent.whenIdle()
 
-      const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+      const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
       expect(contexts).toHaveLength(1)
       expect(adapter.requests).toHaveLength(3)
       expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
@@ -3540,7 +3540,7 @@ describe('dynamic nested workspace context injection', () => {
         agent,
       })
       await appendAdditionalContexts(ctx, agent)
-      const resumed = stubAgent(root, [...agent.session.events])
+      const resumed = stubAgent(root, [...agent.session.snapshotEvents()])
 
       const afterResume = await ctx.tools.execute({
         signal: testToolSignal,
@@ -3574,11 +3574,11 @@ describe('dynamic nested workspace context injection', () => {
       })
       await appendAdditionalContexts(ctx, original)
       await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
 
       await composeBaselinePrefix(ctx, resumed)
 
-      const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+      const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
       expect(update?.type === 'user/message' && update.data.source).toMatchObject({
         changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
@@ -4608,7 +4608,7 @@ describe('workspace context inbox synchronization', () => {
         callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original,
       })
       await syncWorkspaceContext(ctx, original)
-      const resumed = stubAgent(root, [...original.session.events])
+      const resumed = stubAgent(root, [...original.session.snapshotEvents()])
 
       await ctx.tools.execute({
         signal: testToolSignal,

+ 1 - 1
packages/context/session-reference/tests/session-reference.spec.ts

@@ -805,7 +805,7 @@ describe('session reference discovery and preparation', () => {
     expect(JSON.stringify(before)).toContain('durable referenced fact')
     expect(JSON.stringify(before)).toContain('use @source')
     expect(JSON.stringify(before)).not.toContain('later source mutation')
-    expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
+    expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before)
   })
 
   it('rejects direct invalid configuration before service publication', async () => {

+ 3 - 2
packages/context/time-context/src/index.ts

@@ -77,12 +77,13 @@ function formatDuration(elapsedMs: number): string {
 
 /** Collect already-entered and proposed user messages belonging to one open turn. */
 function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
-  const start = agent.session.events.findLastIndex(
+  const events = agent.session.snapshotEvents()
+  const start = events.findLastIndex(
     event => event.type === 'turn/start' && event.data.turn === turn,
   )
   const entered = start < 0
     ? []
-    : agent.session.events.slice(start + 1)
+    : events.slice(start + 1)
       .flatMap(event => event.type === 'user/message' ? [event.data] : [])
   return [...entered, ...proposed]
 }

+ 4 - 3
packages/context/time-context/src/invariant.ts

@@ -161,11 +161,12 @@ function validateReading(
 /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
 /** Validate all package-owned readings already present in one session. */
 function validateSession(session: Session, fail: InvariantFailure): void {
-  for (const [index, event] of session.events.entries()) {
+  const events = session.snapshotEvents()
+  for (const [index, event] of events.entries()) {
     if (event.type !== 'user/message'
       || event.data.source.kind !== 'plugin'
       || event.data.source.plugin !== SOURCE_NAME) continue
-    validateReading(session.events.slice(0, index), event, fail)
+    validateReading(events.slice(0, index), event, fail)
   }
 }
 
@@ -179,7 +180,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
     if (event.type !== 'user/message'
       || event.data.source.kind !== 'plugin'
       || event.data.source.plugin !== SOURCE_NAME) return
-    validateReading(session.events, event, fail)
+    validateReading(session.snapshotEvents(), event, fail)
   }, { global: true })
 }, { inject: ['sessions'] })
 /* jscpd:ignore-end */

+ 13 - 13
packages/context/time-context/tests/time-context.spec.ts

@@ -67,7 +67,7 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string
 
 function contextTexts(session: Session): string[] {
   const texts: string[] = []
-  for (const event of session.events) {
+  for (const event of session.snapshotEvents()) {
     if (event.type === 'user/message'
       && event.data.source.kind === 'plugin'
       && event.data.source.plugin === 'time-context') {
@@ -168,7 +168,7 @@ describe('durable step context', () => {
       + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n'
       + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
     ])
-    const event = session.events.at(-1)
+    const event = session.snapshotEvents().at(-1)
     expect(event?.type).toBe('user/message')
     if (event?.type !== 'user/message') throw new Error('missing time context')
     // The reading is a `snapshot`-form context: one named contribution whose
@@ -291,8 +291,8 @@ describe('durable step context', () => {
     const original = Session.create(SessionId('seed-source'))
     openMessageTurn(original, 1)
     await fire(ctx, sessionAgent(original), 1, 1)
-    const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
-    const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+    const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')
+    const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
     if (user === undefined || reading === undefined) throw new Error('missing source surface events')
     original.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'compacted history' }],
@@ -304,15 +304,15 @@ describe('durable step context', () => {
     original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
 
-    const resumed = Session.create(SessionId('resumed'), [...original.events])
+    const resumed = Session.create(SessionId('resumed'), [...original.snapshotEvents()])
     const resumedAgent = sessionAgent(resumed)
     vi.setSystemTime(BASE + 999)
     openMessageTurn(resumed, 2)
-    const beforeSkip = resumed.events.length
+    const beforeSkip = resumed.snapshotEvents().length
 
     await fire(ctx, resumedAgent, 2, 1)
 
-    expect(resumed.events).toHaveLength(beforeSkip)
+    expect(resumed.snapshotEvents()).toHaveLength(beforeSkip)
     expect(contextTexts(resumed)).toHaveLength(1)
 
     vi.setSystemTime(BASE + 1_000)
@@ -334,14 +334,14 @@ describe('durable step context', () => {
 
     vi.setSystemTime(BASE + 500)
     openMessageTurn(first, 2)
-    const beforeSkip = first.events.length
+    const beforeSkip = first.snapshotEvents().length
     await fire(ctx, firstAgent, 2, 1)
 
     const independent = Session.create(SessionId('interval-independent'))
     openMessageTurn(independent, 1)
     await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
 
-    expect(first.events).toHaveLength(beforeSkip)
+    expect(first.snapshotEvents()).toHaveLength(beforeSkip)
     expect(contextTexts(first)).toHaveLength(1)
     expect(contextTexts(independent)).toHaveLength(1)
   })
@@ -460,7 +460,7 @@ describe('real agent-loop request history', () => {
 
     expect(contextTexts(agent.session)).toHaveLength(0)
     expect(adapter.requests).toHaveLength(0)
-    expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
+    expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false)
     await ctx.fiber.dispose()
   })
 
@@ -482,9 +482,9 @@ describe('real agent-loop request history', () => {
     await agent.whenIdle()
 
     expect(adapter.requests).toHaveLength(2)
-    const contexts = agent.session.events.filter(
+    const contexts = agent.session.snapshotEvents().filter(
       (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
-    const starts = agent.session.events.filter(event => event.type === 'step/start')
+    const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start')
     expect(contexts).toHaveLength(adapter.requests.length)
     expect(starts).toHaveLength(adapter.requests.length)
     for (let index = 0; index < contexts.length; index += 1) {
@@ -504,7 +504,7 @@ describe('real agent-loop request history', () => {
     expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
 
     for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
-    const headers = agent.session.events.filter(event => event.type === 'request/header')
+    const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header')
     expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
     await ctx.fiber.dispose()
   })

+ 2 - 2
packages/context/tmux-context/tests/tmux-context.spec.ts

@@ -121,7 +121,7 @@ function openMessageTurn(session: Session, turn: number): void {
 
 function contextTexts(session: Session): string[] {
   const texts: string[] = []
-  for (const event of session.events) {
+  for (const event of session.snapshotEvents()) {
     if (event.type === 'user/message'
       && event.data.source.kind === 'plugin'
       && event.data.source.plugin === 'tmux-context') {
@@ -169,7 +169,7 @@ describe('tmux-context injection', () => {
       + 'window active=1, pane active=0, '
       + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
     ])
-    const event = session.events.at(-1)
+    const event = session.snapshotEvents().at(-1)
     if (event?.type !== 'user/message') throw new Error('missing tmux context')
     // `snapshot` form: one named contribution carrying exactly the reading the
     // model saw, so a consumer attributes it without re-splitting prose.

+ 1 - 1
packages/core/agent-loop/src/invariant.ts

@@ -28,7 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
       fail('a loop-built request must carry a frozen messages array')
     }
 
-    const events = session.events
+    const events = session.snapshotEvents()
     if (!events.some(event => event.type === 'step/start')) {
       return fail('a loop-built request with no step/start in its session log')
     }

+ 2 - 2
packages/core/agent-loop/src/runtime-context.ts

@@ -33,8 +33,8 @@ export class RuntimeContextProjection {
    */
   constructor(ctx: Context, session: Session) {
     const surface = new Set(session.surface.nodes)
-    for (let index = session.events.length - 1; index >= 0; index -= 1) {
-      const event = session.events[index]
+    for (let index = session.seq - 1; index >= 0; index -= 1) {
+      const event = session.eventAt(index)
       if (event?.type !== 'user/message' || !isOwned(event.data)) continue
       this.retained ??= null
       if (surface.has(event.seq)) {

+ 1 - 1
packages/core/agent-loop/tests/agent-initiator.spec.ts

@@ -334,7 +334,7 @@ describe('AgentLoop initiator scope', () => {
     }])
     const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
     expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
-    const call = handle.agent.session.events.find(event => event.type === 'tool/call')
+    const call = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/call')
     expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
       .toBe(JSON.stringify({ path: '/v1/capability' }))
     expect(captured).toBe(handle.agent)

+ 4 - 4
packages/core/agent-loop/tests/agent.spec.ts

@@ -35,7 +35,7 @@ describe('Agent', () => {
 
     agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
 
-    expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
+    expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced'])
     expect(agent.status).toBe('idle')
     expect(adapter.requests).toHaveLength(0)
     await agent.whenIdle()
@@ -47,7 +47,7 @@ describe('Agent', () => {
 
     agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
 
-    const injected = agent.session.events.at(-1)
+    const injected = agent.session.snapshotEvents().at(-1)
     expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
       .toEqual({ kind: 'plugin', plugin: '' })
   })
@@ -97,7 +97,7 @@ describe('Agent', () => {
     expect(() => {
       agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } }))
     }).toThrow(/non-JSON-serializable/)
-    expect(agent.session.events).toHaveLength(0)
+    expect(agent.session.snapshotEvents()).toHaveLength(0)
   })
 
   it('steer() while idle becomes a woken prompt turn', async () => {
@@ -108,7 +108,7 @@ describe('Agent', () => {
     agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } }))
     await agent.whenIdle()
 
-    expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
+    expect(agent.session.snapshotEvents().some(event => event.type === 'user/message')).toBe(true)
     expect(adapter.requests).toHaveLength(1)
   })
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini