diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 6b72ef7c79..6c6ebd4788 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.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: e4f8b434b87524b1c0816962328fa61f00138b2a -2026-06-11-dev-invariants-over-deep-readonly.zh.md: eaeab626089a9d7abb088bf3a580f05f8c6b13ff +2026-06-11-dev-invariants-over-deep-readonly.md: 7e5f55e8910797bd46674050ea5eb8abbca4aef7 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: c1413e9c89284312af41b6c115b7e50a99d1b5e3 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index e4f8b434b8..7e5f55e891 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index eaeab62608..c1413e9c89 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.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 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 05c4325e05..cc913ae925 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.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: b4566d70c79607bbf736ee02e3e37a79c2391232 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1d1acf00fa6a1efc868c3613715a5ff781e0323a +2026-07-25-web-client-session-scope-and-provide-channel.md: feffb1ac0c5bb91c33e61ea18583202e96bad34d +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5d368b4fd810264cb1b451d583de51e1d4cb232b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index b4566d70c7..feffb1ac0c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.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 JSONL provider's lazy-create contract guarantees a never-appended Session never enters `persistence.list()`, 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 JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, 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. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 1d1acf00fa..5d368b4fd8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.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`——JSONL provider 的 lazy-create 约定保证 never-appended Session 不进入 `persistence.list()`,所以 blank 从不落盘。 +- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml index de962f5749..58dcf1ee20 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md index 14da6b3cbe..bd4b3b9f77 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md index f62a8e52a6..eafe663215 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md +++ b/.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 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml index 6f00f147f3..18313964ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md index eab53c6838..309d9dc6bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md index 2cd0d2ca20..28acd3ebe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md +++ b/.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 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml new file mode 100644 index 0000000000..c002ef3859 --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md new file mode 100644 index 0000000000..81cdd77088 --- /dev/null +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md new file mode 100644 index 0000000000..e5d0425981 --- /dev/null +++ b/.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 不模拟数组;调用方只在需要数组操作时物化。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml new file mode 100644 index 0000000000..d8166a7a02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.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-31-viewport-activated-syntax-highlighting.md +2026-08-31-viewport-activated-syntax-highlighting.md: 050c4e50240f9e1d1eb4f2708df6bc93c3a1faf1 +2026-08-31-viewport-activated-syntax-highlighting.zh.md: abd02161ae00d8bdd31863826565175e1d8fddf9 diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md new file mode 100644 index 0000000000..050c4e5024 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md @@ -0,0 +1,41 @@ +# Agent Note: Viewport-activated syntax highlighting + +Status: implemented + +English | [中文](2026-08-31-viewport-activated-syntax-highlighting.zh.md) + +## Problem + +A long conversation mounts code fences and read cards far outside the visible viewport. Eager highlighting tokenizes every supported block and creates every token span during that mount, so main-thread work and DOM size scale with the whole rendered history rather than the code the reader can see. The [Shiki selection](../process/2026-07-26-web-syntax-highlighting-shiki.md) and [incremental streaming highlighter](../feature/2026-08-20-web-streaming-fence-highlight.md) bound initialization and repeated prefix work, but neither avoids the first per-block tokenization for unseen history. + +## Decision + +`useViewportHighlighting` owns one shared `IntersectionObserver` for syntax-highlightable `CodeBlock` and `ReadBlock` instances. A supported block renders its existing plain-text arm until its root first intersects the viewport. An absent or unsupported language never registers with the observer. A browser without `IntersectionObserver` activates highlighting after mount so the capability still works. + +The first intersecting entry removes its target from the observer and activates that component for the rest of its lifetime. Leaving the viewport never returns it to plain text. This one-way transition avoids repeated tokenization, token-DOM construction, and visual churn while scrolling. The shared observer disconnects when no inactive registered blocks remain. + +`CodeBlock` gates both settled `highlightToHtml` calls and streaming `StreamingHighlightSession` creation. It starts an activated stream from the current accumulated source, then retains the existing incremental tokenizer and React line caches. `ReadBlock` gates `highlightLines` while retaining its line rows and gutter. The plain and highlighted arms keep the same source text, code font, padding, wrapping, and line height; Shiki's color, bold, italic, and underline token styles remain unchanged. + +The module-level Shiki singleton warm-up remains eager. Viewport activation defers code-block content tokenization and token-span construction, not the fixed boot-grammar warm-up or the plain content DOM. + +## Testing + +The focused jsdom test replaces the process-global `IntersectionObserver`, mounts several code surfaces, and proves that non-intersecting and unsupported blocks remain plain, intersecting blocks share one observer, leaving the viewport does not remove highlighting, and an activated block continues to highlight changed source. It also covers read-card activation and observer disposal. Each test restores the global and unmounts every component, so the module-level registry cannot leak registrations into another case. + +Existing component tests run without `IntersectionObserver` and therefore cover the immediate fallback together with the established Shiki output, font styles, streaming caches, and plain-language behavior. Browser geometry is not measured by this unit suite; geometry stability relies on the unchanged shared typography and box styles of the plain and highlighted arms. + +## Alternatives considered + +**Deactivate highlighting when a block leaves the viewport.** This can reclaim token DOM from blocks already viewed, but scrolling repeatedly rebuilds the same token tree, discards streaming caches, and changes visible presentation at both viewport edges. One-way activation pays the cost at most once per mounted block. + +**Drop bold and italic token styles to make every token use identical font metrics.** This weakens syntax presentation, especially for highlighted Markdown, and is unnecessary for the chosen lifecycle: both render arms already use the same code font and fixed line height. Shiki's existing token styles remain intact. + +**Pin a measured pixel height during activation.** A fixed measurement becomes stale when a streaming fence grows or responsive wrapping changes, and can clip content or introduce an inner vertical scrollbar. The plain arm stays in normal flow instead of adding measurement state. + +**Virtualize complete code blocks or retain only a token window.** This can also bound DOM after a reader has visited every block, but it changes selection, copy, scroll anchoring, and streaming-cache ownership. Viewport activation removes unseen work without changing those behaviors. + +## Consequences + +Supported code that is never viewed incurs no content tokenization and creates no token spans. The first viewport intersection pays the normal synchronous highlight cost; a lazily imported grammar may keep the block plain until its existing load notification arrives. Activated blocks retain their highlighted DOM when scrolled away, so memory use grows with blocks the reader has visited rather than shrinking with the current viewport. + +The optimization is local to presentation. Markdown parsing, Shiki grammar selection and styling, stream-tail tokenization, copy text, and settled output remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md new file mode 100644 index 0000000000..abd02161ae --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 视口激活的语法高亮 + +Status: implemented + +[English](2026-08-31-viewport-activated-syntax-highlighting.md) | 中文 + +## 问题 + +长对话会挂载远在可见视口之外的代码围栏和读取卡片。预先高亮会在挂载时 tokenize 每个受支持的块并创建全部 token span,使主线程工作量和 DOM 大小随完整渲染历史增长,而不是随读者能看到的代码增长。[Shiki 选型](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md)与[流式增量高亮](../feature/2026-08-20-web-streaming-fence-highlight.zh.md)分别约束初始化成本和重复处理前缀的成本,但都无法避免不可见历史首次发生的逐块 tokenize。 + +## 决策 + +`useViewportHighlighting` 为可进行语法高亮的 `CodeBlock` 和 `ReadBlock` 实例持有一个共享的 `IntersectionObserver`。受支持的块在根元素首次与视口相交之前渲染既有的纯文本臂。语言缺失或不受支持时不会向 observer 注册。浏览器不提供 `IntersectionObserver` 时会在挂载后激活高亮,使该能力仍然可用。 + +首个相交条目会从 observer 中移除自己的目标,并在该组件余下的生命周期里保持激活。离开视口不会恢复为纯文本。这种单向转换避免滚动时反复 tokenize、创建 token DOM 和发生视觉切换。不存在尚未激活的注册块时,共享 observer 会断开。 + +`CodeBlock` 同时控制定稿态的 `highlightToHtml` 调用和流式 `StreamingHighlightSession` 的创建。流式块激活时从当前累积源码开始,随后保留既有的增量 tokenizer 与 React 行缓存。`ReadBlock` 控制 `highlightLines`,同时保留其行和行号槽。纯文本臂与高亮臂使用相同的源码文本、代码字体、内边距、换行规则和行高;Shiki 现有的 token 颜色、粗体、斜体和下划线样式保持不变。 + +模块级 Shiki 单例仍然预先预热。视口激活延迟的是代码块内容的 tokenize 与 token span 创建,不是固定的启动语法预热或纯文本内容 DOM。 + +## Testing + +聚焦的 jsdom 测试替换进程全局 `IntersectionObserver`,挂载多个代码表面,并证明未相交和不受支持的块保持纯文本、相交块共用一个 observer、离开视口不会移除高亮,且已激活的块会继续高亮变化后的源码。测试还覆盖读取卡片激活和 observer 释放。每个测试都会恢复全局值并卸载全部组件,因此模块级注册表不会把注册项泄漏到其他用例。 + +既有组件测试在没有 `IntersectionObserver` 的环境中运行,因此同时覆盖立即回退路径,以及既有 Shiki 输出、字体样式、流式缓存和纯文本语言行为。该单元测试套件不测量浏览器几何尺寸;几何稳定性依赖纯文本臂与高亮臂不变的共享字体排印和盒模型样式。 + +## 曾考虑的替代方案 + +**块离开视口时停用高亮。** 这可以回收已经看过的块所占用的 token DOM,但滚动会反复重建同一棵 token 树、丢弃流式缓存,并在视口两端改变可见呈现。单向激活使每个已挂载块最多支付一次成本。 + +**移除粗体和斜体 token 样式,使所有 token 使用完全相同的字体度量。** 这会削弱语法呈现,尤其影响高亮后的 Markdown;所选生命周期也不需要这一取舍,因为两个渲染臂已经使用相同的代码字体和固定行高。Shiki 现有 token 样式保持不变。 + +**在激活时锁定测量得到的像素高度。** 当流式围栏增长或响应式换行变化时,固定测量会陈旧,并可能裁切内容或引入内部纵向滚动条。纯文本臂继续处于正常文档流中,不增加测量状态。 + +**虚拟化完整代码块,或仅保留一个 token 窗口。** 这也能在读者访问所有块之后限制 DOM,但会改变文本选择、复制、滚动锚定与流式缓存的归属。视口激活消除不可见工作,同时不改变这些行为。 + +## 后果 + +从未进入视口的受支持代码不会发生内容 tokenize,也不会创建 token span。首次与视口相交时支付普通的同步高亮成本;若语法采用懒加载,代码块可能继续保持纯文本,直到既有的加载通知到达。已激活的块滚出视口后会保留高亮 DOM,因此内存占用随读者访问过的块增长,而不会随当前视口缩减。 + +该优化仅作用于呈现层。Markdown 解析、Shiki 语法选择与样式、流式尾行 tokenize、复制文本和定稿输出均保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 7593352560..d8a34eb622 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 72cafb5a1a..00f4cf0913 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 79fcc56b7b..631ba71d84 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.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 只决定接下来发送给模型的内容。缺陷完全在投影层。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 0bf2cfa13f..abaae859ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index daf11b4123..a23e51771c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index 6ea2f8814c..12fd41af0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.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` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index 6b870f06a2..062c3eff4c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.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: 912842a9d59fd49491a53f987d172d64d4c0e101 -2026-08-05-context-form-vocabulary.zh.md: 0d4e1d44e4d22b8bfcb6c73bb015bc3240381bd3 +2026-08-05-context-form-vocabulary.md: be792256fa3375dd2621d647d6d3074b1a6d6b18 +2026-08-05-context-form-vocabulary.zh.md: f648dd1071128b879e8c36e9fba1646b6c70cda1 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 912842a9d5..be792256fa 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.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 persistence 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 persistence 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. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 0d4e1d44e4..f648dd1071 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.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 时它来自持久化 seed,seed 验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该 Session 此后每一步都在最晚、最难定位的点失败。 +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.snapshotEvents()` 读 `source.entries`,而恢复或 fork 时它来自持久化 seed,seed 验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index b959794f2d..5381b7f346 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.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-durable-web-schedule.md -2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc -2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251 +2026-08-05-durable-web-schedule.md: bac4a5cfd8965032dad2cf689ca42b1f8e5da1e3 +2026-08-05-durable-web-schedule.zh.md: 266a90d3a56caccda56be9fe648c059939b8155f diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index 2a07d8257d..bac4a5cfd8 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -60,7 +60,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing ### Read-only Web catalog -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. +The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction. ## Alternatives considered @@ -80,7 +80,7 @@ The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](. ## Verification -Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one Schedule-catalog smoke for overlay reachability, the current header catalog, ordinary/search alarms, narrow dark layout, and one live empty update. +Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index c3a37f69ab..266a90d3a5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -60,7 +60,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒 ### 只读 Web 目录 -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。 +Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。 ## 已考虑的替代方案 @@ -80,7 +80,7 @@ Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../pa ## 验证 -包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 Schedule 目录 smoke 覆盖 overlay 可达性、当前 header 目录、普通/搜索闹钟、窄屏暗色布局与一次 live empty 更新。 +包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index ad9994bc34..72e8ad9a15 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index ce07e63681..9dab135029 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 9657c7dc92..824aa1a04f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.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 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml index 2139112dec..bd978d1920 100644 --- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.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-20-web-streaming-fence-highlight.md -2026-08-20-web-streaming-fence-highlight.md: ccc961da1febba3611e3e558087b586c6f1f474b -2026-08-20-web-streaming-fence-highlight.zh.md: e5d29545bf659ed572d052f1212d68d344d49b5a +2026-08-20-web-streaming-fence-highlight.md: aa44a8c7ea91ff4913d92449d62a6ebf9406e922 +2026-08-20-web-streaming-fence-highlight.zh.md: 88bae36928ac8d00960e529addeb4f30240e3579 diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md index ccc961da1f..aa44a8c7ea 100644 --- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md @@ -10,17 +10,18 @@ While a reply streamed, `MarkdownText` stripped the fence language before `CodeB ## Decision -Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged. +Streaming fences parse, tokenize, and reconcile from retained frontiers; the settled output is unchanged. -- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`), and each update tokenizes newly completed text via `codeToTokensBase(…, { grammarState })` plus the still-growing last line. Per-chunk cost excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting — so the streaming spans and the settled `codeToHtml` swap render one identical span tree. -- **`CodeBlock`** gains a `streaming` prop: it renders the session's spans as a `pre.shiki.css-variables` React tree with the same attributes shiki's HTML emits, holds the session and per-line elements in refs, and reuses a retained line's element identity so React leaves that line's DOM untouched. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back. -- **`render.tsx`** passes `lang` and `context.streaming` to fences. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. The streaming CodeBlock instance survives every chunk because streaming render keys are source offsets. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
+- **`IncrementalMarkdownParser`** (`packages/client/ui-primitives/src/markdown/incremental.ts`) recognizes a parser-confirmed final unclosed top-level fence after the ordinary tail parse. Completed content remains in the retained `code` node; only the last completed line and current partial line pass through the caller's GFM grammar, preserving its newline, indentation, CRLF, and value semantics without re-parsing the fence prefix. A closing delimiter, non-append input, nested/container fence, or ambiguous reconstruction returns to the ordinary tail parse.
+- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`); `updateFrame` publishes only newly completed lines plus the still-growing last line, while the compatibility `update` method materializes the complete result. Per-chunk tokenization excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting.
+- **`CodeBlock`** renders the delta frames as a `pre.shiki.css-variables` React tree with the same attributes and token spans shiki's HTML emits. Completed lines seal into fixed-size React fragments; later updates reuse those fragment elements and reconcile only a bounded pending group plus the mutable tail. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back. Group size is an internal reconciliation unit, not a deployment policy or content limit.
+- **`render.tsx` and `MarkdownText`** pass `lang` and `context.streaming` to fences and key both streaming and settled top-level blocks by source offset. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
 
-The settle swap re-renders through `highlightToHtml`: same tokens, same span tree, so the swap is visually invisible and never touches the code content.
+The final full-document parse still resolves document-wide references and math. When that parse produces the same fence code and language, the source-offset key preserves its `CodeBlock` instance and the component reuses the complete streamed React tree; cold settled fences continue through `highlightToHtml`.
 
 ## Testing
 
-Package tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; cache identity and reset/lazy paths; streaming/settled token-tree parity; DOM retention; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
+Package tests bound the grammar input accumulated across 800 open-fence lines, compare each incremental result with a full parse, and cover indented delimiters, CRLF split across chunks, closure fallback, and non-append reset. Highlighter tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; delta identity and reset/lazy paths; fixed-group DOM retention; streamed-to-settled DOM identity; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
 
 ## Alternatives considered
 
@@ -28,10 +29,12 @@ Package tests cover incremental/from-scratch equivalence across multiline gramma
 
 **Highlight only frozen (closed, settled-position) fences during streaming.** Bounded cost, but an unclosed fence pins the incremental parser's tail, so the actively growing fence — the one on screen — would stay plain until the reply finishes, failing the issue's "识别语言后即可增量高亮".
 
+**Keep only a fixed window of highlighted lines and turn the older prefix into plain text.** This bounds live token DOM and can reduce layout further, but changes already rendered content, complicates selection across the window boundary, and makes a tunable presentation policy part of `CodeBlock`. Retained parser, tokenizer, and React frontiers remove the avoidable repeated work without discarding colors; the complete token DOM remains an explicit limitation rather than a hidden semantic change.
+
 **Move highlighting to a worker or async pass.** Rejected when shiki was adopted ([synchronous highlighting note](../process/2026-07-26-web-syntax-highlighting-shiki.md)); an async swap also reintroduces the plain→colored→plain flicker class this change must avoid.
 
 **Build the settled HTML string incrementally and keep `dangerouslySetInnerHTML`.** Exact settled parity for free, but React replaces the whole `innerHTML` per chunk, so the browser re-parses and rebuilds every line's DOM each time — O(fence) DOM churn that forfeits the token-level win the session provides.
 
 ## Consequences
 
-Streaming code is readable as it arrives: tokens color as soon as the language is known, completed lines never re-tokenize or re-render, and the finalize swap is invisible for fences. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The still-growing last line re-tokenizes per chunk (bounded by one line), and a pathological single-line fence still degrades to full re-tokenization per chunk — the same degradation class the incremental block parser accepts for a single giant block.
+Streaming code is readable as it arrives: tokens color as soon as the language is known; completed top-level fence content neither re-parses nor re-tokenizes; sealed React groups keep their elements and DOM; and settlement preserves the highlighted tree. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The retained DOM still grows with final token count, so browser style and layout work is not length-independent. Nested/container fences use the ordinary tail parser, and the still-growing last line re-tokenizes per chunk; a pathological single-line fence therefore remains the worst case.
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
index e5d29545bf..88bae36928 100644
--- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
@@ -10,17 +10,18 @@ Status: implemented
 
 ## Decision
 
-流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+流式围栏从保留的解析、tokenize 与 reconcile 前沿继续推进;定稿输出保持不变。
 
-- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`),每次更新通过 `codeToTokensBase(…, { grammarState })` tokenize 新完成的文本,外加仍在增长的最后一行。每分片成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致——因此流式 span 与定稿 `codeToHtml` 换入的 span 树完全一致。
-- **`CodeBlock`** 新增 `streaming` prop:把会话的 span 渲染为带有 shiki HTML 同款属性的 `pre.shiki.css-variables` React 树,用 ref 持有会话与逐行元素,并复用保留行的元素标识,让 React 完全不触碰该行的 DOM。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。
-- **`render.tsx`** 向围栏传递 `lang` 与 `context.streaming`。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 `
`。流式渲染 key 是源偏移,围栏的 CodeBlock 实例因此跨分片存活。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+- **`IncrementalMarkdownParser`**(`packages/client/ui-primitives/src/markdown/incremental.ts`)会在普通尾部解析后识别经 parser 确认、位于末尾且未闭合的顶层 fence。已完成内容保留在既有 `code` node 中;只有最后一个已完成行与当前未完成行再次进入调用方的 GFM grammar,因此无需重新解析 fence 前缀,也能保留其换行、缩进、CRLF 与 value 语义。出现闭合分隔符、非追加输入、嵌套/容器内 fence 或无法明确重建时,会回到普通尾部解析。
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`);`updateFrame` 只发布新完成行与仍在增长的最后一行,兼容方法 `update` 则物化完整结果。每分片 tokenize 成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致。
+- **`CodeBlock`** 把增量 frame 渲染为带有 shiki HTML 同款属性与 token span 的 `pre.shiki.css-variables` React 树。已完成行会封入固定大小的 React fragment;后续更新复用这些 fragment element,只 reconcile 一个有界的待完成分组与可变尾部。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。分组大小只是内部 reconcile 单元,不是部署策略或内容上限。
+- **`render.tsx` 与 `MarkdownText`** 向围栏传递 `lang` 与 `context.streaming`,并让流式和定稿的顶层 block 都按源偏移设置 key。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 `
`。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
 
-定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+最终的全量文档解析仍会解决跨文档引用与数学语法。当该解析产生相同的 fence 代码与语言时,源偏移 key 会保留其 `CodeBlock` 实例,组件则复用完整的流式 React 树;冷启动的定稿 fence 继续使用 `highlightToHtml`。
 
 ## Testing
 
-包测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,缓存标识与重置/懒加载路径,流式/定稿 token 树一致性,DOM 保留,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
+包测试会约束 800 行未闭合 fence 的累计 grammar 输入量、逐次比较增量结果与全量解析,并覆盖缩进分隔符、跨分片 CRLF、闭合回退与非追加重置。高亮测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,delta 标识与重置/懒加载路径,固定分组的 DOM 保留,从流式到定稿的 DOM 标识,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
 
 ## Alternatives considered
 
@@ -28,10 +29,12 @@ Status: implemented
 
 **流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
 
+**只保留固定窗口内的高亮行,并把更早的前缀转成纯文本。** 这能限制流式 token DOM 并进一步降低布局成本,但会改变已经渲染的内容、让跨窗口边界的选择更复杂,还会把可调的展示策略塞进 `CodeBlock`。保留解析、tokenize 与 React 前沿可以在不丢颜色的情况下消除可避免的重复工作;完整 token DOM 被明确记录为限制,而不是隐藏的语义变化。
+
 **把高亮移到 worker 或异步流程。** 采纳 shiki 时已否决([同步高亮笔记](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md));异步换入还会重新引入本变更必须避免的纯文本→彩色→纯文本闪烁类问题。
 
 **增量拼接定稿 HTML 字符串并继续使用 `dangerouslySetInnerHTML`。** 白得定稿一致性,但 React 每个分片都会整体替换 `innerHTML`,浏览器每次重新解析并重建所有行的 DOM——O(围栏) 的 DOM 翻搅,抵消了会话在 token 层的收益。
 
 ## Consequences
 
-流式代码随到达即可读:语言一经识别 token 即着色,已完成行绝不重新 tokenize 或重渲染,定稿切换对围栏而言不可见。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。仍在增长的最后一行每分片重新 tokenize(以一行为界);病态的单行超长围栏仍退化为每分片完整重新 tokenize——与增量块解析器对单个巨型块接受的是同一退化类。
+流式代码随到达即可读:语言一经识别 token 即着色;顶层 fence 的已完成内容不再重新解析或 tokenize;封存的 React 分组保留其 element 与 DOM;定稿也会保留高亮树。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。保留的 DOM 仍随最终 token 数增长,因此浏览器 style 与 layout 工作量并非与长度无关。嵌套/容器内 fence 使用普通尾部解析器,仍在增长的最后一行则会每分片重新 tokenize;病态的单行超长 fence 因而仍是最坏情况。
diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml
index 2a8c4bbcb5..697f943349 100644
--- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md
-2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89
-2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48
+2026-08-24-user-authorized-subagent-model-routes.md: ef6f90d2cc1ec79e1381d75fd8cc25c568af3067
+2026-08-24-user-authorized-subagent-model-routes.zh.md: 23519bfc6619718e1cbf98dbad65eccd90353c0a
diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
index fce0026f65..ef6f90d2cc 100644
--- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
+++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
@@ -12,7 +12,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an
 
 The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process.
 
-A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled.
+A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session.
 
 The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice.
 
diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md
index 2cdf42dd33..23519bfc66 100644
--- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md
@@ -12,7 +12,7 @@ Status: implemented
 
 Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。
 
-设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。
+设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。
 
 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。
 
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
index 0ba01f1eeb..2fd847ed20 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.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-25-loaded-turn-chat-navigation.md
-2026-08-25-loaded-turn-chat-navigation.md: 5d9d93b07f7a8c527bf7376bf111c6a709afa4d1
-2026-08-25-loaded-turn-chat-navigation.zh.md: 21dba024710b306848fee9bc4fe1f16913c475b3
+2026-08-25-loaded-turn-chat-navigation.md: b01e64d23fbc87f61c0de5b32cdf293c8d0afa94
+2026-08-25-loaded-turn-chat-navigation.zh.md: 85397c4a33cbc4ff1a08d253845f0112fe9b051b
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
index 5d9d93b07f..b01e64d23f 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
@@ -18,7 +18,7 @@ The rail renders the complete loaded Turn set with a 10px natural interval and n
 
 The rail sits against the scrollport's right edge and centers on the band the sticky composer leaves visible. That band is the scrollport's own height minus the seat's, so ConversationRoot publishes `--dsh-conversation-viewport-height` beside the `--dsh-composer-height` it already measures on the same element, and the rail centers on their difference instead of a viewport height that ignores the Session header.
 
-The active mark follows a reading line near the top of the shared Chat scrollport. A scroll frame resolves the owning Turn with one hit test at that line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
+The active mark follows a reading line near the top of the shared Chat scrollport. A pinned frame selects the final loaded Turn from scroll distance before reading any row geometry; streaming and other observed height changes can therefore follow the floor without a hit test or scan. Away from the floor, a scroll frame resolves the owning Turn with one hit test at the reading line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
 
 Every Turn remains an accessible button even when dense marks visually overlap. The rail maps pointer height to the nearest loaded Turn, while keyboard focus and activation operate the individual buttons. Hover and focus show a compact prompt-and-response preview, the active mark is longer and darker, the rail is hidden when the Chat container is at most 900px wide, and reduced-motion preferences disable redistribution and mark-entry animation.
 
@@ -42,4 +42,4 @@ Desktop-width Chat views can jump among all currently loaded Turns and inspect a
 
 ## Testing
 
-Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
+Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, percentage redistribution after prepend, and a pinned `ResizeObserver` update that rejects every row-geometry read. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
index 21dba02471..85397c4a33 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
@@ -18,7 +18,7 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
 
 轨道紧贴滚动视口右缘,并在粘性输入区之外的可见区间内垂直居中。该区间等于滚动视口自身高度减去输入区高度,因此 ConversationRoot 在同一元素上除已有的 `--dsh-composer-height` 外再发布 `--dsh-conversation-viewport-height`,轨道按两者之差居中,而不是按忽略 Session 头部的视口高度居中。
 
-活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。每个滚动帧用一次命中测试解析该行所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。图片加载、工具卡展开等不产生滚动事件的高度变化,通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
+活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。跟随底部的 frame 会先按滚动距离选中最后一个已加载 Turn,不读取任何行几何;流式输出及其他被 observer 捕获的高度变化因此无需命中测试或扫描即可追随底部。离开底部后,每个滚动 frame 用一次命中测试解析阅读线所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。不产生滚动事件却让行跨过阅读线的高度变化通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
 
 即使密集刻度在视觉上重叠,每个 Turn 仍是可访问的按钮。轨道把指针高度映射到最近的已加载 Turn,键盘聚焦和激活则作用于各个按钮。悬停或聚焦显示紧凑的问题与回复预览,活跃刻度更长、更深;Chat 容器宽度不超过 900px 时隐藏轨道,用户偏好减少动态效果时关闭重排和刻度入场动画。
 
@@ -42,4 +42,4 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
 
 ## 测试
 
-构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
+构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份、前插后的百分比重排,以及拒绝任何行几何读取的底部跟随 `ResizeObserver` 更新。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
new file mode 100644
index 0000000000..935dd280d4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+2026-08-30-web-turn-rail-outline-jump.md: af2a464513d108429859c4f9ed8b4bfc58175e3c
+2026-08-30-web-turn-rail-outline-jump.zh.md: b63b1c1f656689997b8a8266ee332dfdb47d4035
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
new file mode 100644
index 0000000000..af2a464513
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -0,0 +1,35 @@
+# Agent Note: Full-session turn rail with outline projection and jump paging
+
+Status: implemented
+
+English | [中文](2026-08-30-web-turn-rail-outline-jump.zh.md)
+
+## Problem
+
+The web chat's turn rail derived its marks from the loaded event window, and the window is a paged suffix of the log (50-message tail, `Load earlier` per page). In a long session the rail therefore named only the most recent turns: history that had not been paged in was invisible to navigation, unreachable except by clicking `Load earlier` repeatedly, and the rail squeezed whatever it did show into a fixed frame by compressing mark spacing to percentages, so a many-turn session degenerated into an unreadable dense strip.
+
+## Decision
+
+Three cooperating pieces, each useful alone.
+
+**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
+
+**Change-feed identity gate (session-projection).** Each live unit cell keeps `[previousView, currentView]` raw outputs. When a state reference changes, the drive shifts current to previous; while a change listener exists it computes `view(nextState)` once, stores current, and emits only when the two outputs differ by `Object.is`. Without listeners, current becomes `undefined` without evaluating `view`, so the first later computed value publishes conservatively; catch-up folding invalidates current in the same way. This lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — is rejected because every quiet change would pay for full serialization.
+
+**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
+
+**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark releases bottom ownership on the click itself (jumping into history is leaving the live tail; otherwise the pinned-scroll snap racing the first prepend's compensation would call `toBottom` and cancel the jump), holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
+
+## Alternatives considered
+
+**Sparse or segmented windows** (load only the target turn's neighbourhood): rejected — window contiguity is the foundation the transport validation, assembler, timeline, and scroll anchoring all share; a discontiguous window is a different architecture, deferred until sessions outgrow full paging.
+
+**A `minSeq` page bound on the wire** (one targeted request instead of the client loop): rejected for v1 — the loop needs no protocol change, yields natural per-chunk progress, and Codex's TUI uses the same recursive page-pull shape for its jump-to-start; the single unbounded frame can be revisited if round trips ever dominate.
+
+**A dedicated outline RPC**: rejected — the projection seam already provides the consistency cut with the tail page, live push, persistence, and capability-absence fallback that a bespoke endpoint would have to rebuild.
+
+**Estimated row heights for jump positioning**: rejected — transcript rows vary wildly (tool cards, images, code), so estimation jitters; landing after commit is exact and matches Codex's deferred `pending_scroll_chunk` realization.
+
+## Consequences
+
+The rail is now session-scoped rather than window-scoped, at the cost of a whole-value projection that grows with the session (up to ~600 bytes per turn at full CJK preview budgets, pushed at most three times per turn); splitting previews into an on-demand read is deferred until multi-thousand-turn sessions need it. A deep jump still loads every intervening page — the contiguous-window contract — so jumping to turn 1 of a huge session materializes the whole transcript, as manual paging always did. Assemblies without the projection plugin keep the old loaded-only rail. Coverage: projection unit + Loader-composition + HMR specs in the new package, `loadThrough` loop specs in session-controller, merge and jump specs in ui-chat (including the settle correction and busy lifecycle), and a browser contract in the chat-scroll e2e that drives a keyboard jump from an 88-turn fixture's tail to its unloaded first turn and asserts the landing geometry and rail fades.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
new file mode 100644
index 0000000000..b63b1c1f65
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 基于大纲投影与跳转分页的整会话轮次导航栏
+
+Status: implemented
+
+[English](2026-08-30-web-turn-rail-outline-jump.md) | 中文
+
+## Problem
+
+Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口是日志的分页后缀(50 条 message 的尾页,每次 `加载更早` 一页)。长会话里导航栏因此只列出最近的轮次:尚未分页载入的历史对导航不可见,除了反复点 `加载更早` 无法到达,且导航栏把已显示的刻度按百分比压缩进固定外框,多轮会话退化成不可读的密集条带。
+
+## Decision
+
+三个相互配合、各自独立可用的部分。
+
+**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
+
+**变更流身份门(session-projection)。** 每个实时单元 cell 保存 `[previousView, currentView]` 原始输出。state 引用变化时,drive 先把 current 移到 previous;存在变更 listener 时只计算一次 `view(nextState)` 并写入 current,两个输出通过 `Object.is` 判定为不同时才发出通知。没有 listener 时不计算 `view`,而是把 current 写成 `undefined`,因此之后首次计算出的值会保守地发布;补折叠也以相同方式使 current 失效。这让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的 state 里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+
+**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
+
+**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度在点击当下即交出钉底所有权(跳进历史就是离开活跃尾部;否则钉底吸附与首个 prepend 补偿的竞态会触发 `toBottom` 取消跳转),再用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
+
+## Alternatives considered
+
+**稀疏/分段窗口**(只加载目标轮附近):拒绝——窗口连续性是 transport 校验、assembler、timeline 与滚动锚定共同依赖的地基;不连续窗口是另一套架构,推迟到会话规模超出全量分页时再议。
+
+**wire 上加 `minSeq` 页边界**(单发定向请求替代客户端循环):v1 拒绝——循环零协议改动、自带逐片进度,Codex TUI 的跳到开头也是同款递归拉页形态;若往返耗时成为瓶颈再重启无界单帧方案。
+
+**专用大纲 RPC**:拒绝——投影 seam 已自带与尾页的一致性切面、实时推送、持久化与能力缺席回退,专用端点得重造这一切。
+
+**估算行高定位跳转**:拒绝——transcript 行高方差极大(工具卡、图片、代码),估算必抖;提交后落点零误差,且与 Codex 延迟兑现的 `pending_scroll_chunk` 同构。
+
+## Consequences
+
+导航栏从窗口口径变为会话口径,代价是随会话增长的整值投影(全中文预览预算下每轮上限约 600 字节,每轮至多推送三次);把预览拆成按需读取推迟到数千轮量级的会话真正需要时。深跳仍会加载沿途所有页——连续窗口契约——跳到超长会话的第 1 轮会实体化整个 transcript,与手动翻页的终态相同。未挂载该投影插件的装配保留旧的仅已加载导航。覆盖:新包的投影单元 + Loader 组合 + HMR 测试、session-controller 的 loadThrough 循环测试、ui-chat 的合并与跳转测试(含 settle 校正与忙碌生命周期),以及 chat-scroll e2e 里的浏览器契约——在 88 轮 fixture 的尾部用键盘跳到未加载的第 1 轮,断言落点几何与导航栏渐变。
diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
index 7ce81010a9..fded384582 100644
--- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml
+++ b/.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
diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
index 7309297af8..f0cf1b0602 100644
--- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md
+++ b/.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
 
diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
index 890493ae7d..8e8793f28b 100644
--- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md
+++ b/.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`,供摘要器与工具共享。
 
 ### 缓存与成本
 
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 013c6853ea..c589218149 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts
index 42b87eff8a..f6155102e3 100644
--- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts
+++ b/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
diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
index 03de26d878..7b22be2958 100644
--- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts
+++ b/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')
diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
index a8825cc84d..cf55e35d28 100644
--- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts
+++ b/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)
diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts
index 495b7ebbd2..e09be01d0e 100644
--- a/apps/cli/tests/profiles/headless/tests/harness.ts
+++ b/apps/cli/tests/profiles/headless/tests/harness.ts
@@ -96,7 +96,7 @@ export function waitForIdle(ctx: Context, agent: Agent): Promise {
   })
 }
 
-export function finalText(events: SessionEvent[]): string {
+export function finalText(events: readonly SessionEvent[]): string {
   const message = events.findLast(event => event.type === 'assistant/message')
   if (message?.type !== 'assistant/message') return ''
   return message.data.message.content
diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts
index 0f076fd30e..fba29b93b3 100644
--- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts
+++ b/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: readonly 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: readonly 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')
diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts
index 6767357b4e..a47c01c4b6 100644
--- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts
+++ b/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)
 })
diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts
index 014f1e3e69..5fa792b672 100644
--- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts
+++ b/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')
diff --git a/apps/web/package.json b/apps/web/package.json
index 9896155714..f993d39587 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts
index 1030369886..284b359eeb 100644
--- a/apps/web/tests/background-job-list.e2e.ts
+++ b/apps/web/tests/background-job-list.e2e.ts
@@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
     expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
 
-    const idle = page.getByRole('button', { name: '1 background job' })
+    const idle = page.getByRole('button', { name: '1 background job', exact: true })
     await idle.waitFor({ timeout: 20_000 })
 
     const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts
index 8e4a5769ef..5e58d868c5 100644
--- a/apps/web/tests/chat-continuous-conversation.e2e.ts
+++ b/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 =>
diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts
index 5e3c02a436..15d598a58f 100644
--- a/apps/web/tests/chat-long-interactions.e2e.ts
+++ b/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`)
@@ -195,43 +195,44 @@ describe('web e2e: long Chat interaction contract', () => {
 
     const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' })
     await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 })
-    const initialTurnButtons = turnNavigation.getByRole('button')
-    const initialTurnCount = await initialTurnButtons.count()
-    expect(initialTurnCount).toBeGreaterThan(1)
-    expect(await initialTurnButtons.last().getAttribute('aria-current')).toBe('true')
-    const firstTurnButton = initialTurnButtons.first()
-    const firstTurnLabel = await firstTurnButton.getAttribute('aria-label')
-    if (firstTurnLabel === null) throw new Error('first Turn navigation mark has no accessible label')
-    const firstTurn = Number(firstTurnLabel.match(/^Jump to turn (\d+)$/)?.[1])
-    expect(Number.isSafeInteger(firstTurn)).toBe(true)
+    // The whole-log outline offers every fixture turn before any paging, with
+    // the live tail mark current.
+    const marks = turnNavigation.getByRole('button')
+    await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS)
+    expect(await marks.last().getAttribute('aria-current')).toBe('true')
+    // The oldest turn is an unloaded mark whose outline preview already
+    // carries both the prompt and the settled response.
+    const firstTurnButton = turnNavigation
+      .getByRole('button', { name: 'Load and jump to turn 1', exact: true })
     await firstTurnButton.focus()
     const preview = page.getByRole('tooltip')
     await preview.waitFor({ state: 'visible', timeout: 5_000 })
-    // The first loaded Turn may begin mid-Turn at a page boundary. Its mark is
-    // still useful with the loaded response and gains the prompt after prepend.
-    expect(await preview.textContent()).toContain(`Turn ${String(firstTurn)}`)
-    expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn))
+    expect(await preview.textContent()).toContain(FIXTURE.markers.user(1))
+    expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1))
     const firstTurnPosition = await firstTurnButton.evaluate(button => (
-      button.parentElement?.style.getPropertyValue('--turn-position') ?? ''
+      button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
     ))
-    expect(firstTurnPosition).toBe('0%')
+    expect(firstTurnPosition).toBe('0px')
 
     const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true })
+    const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / })
+    const loadedBefore = await loadedMarks.count()
     await loadEarlier.click()
-    await expect.poll(() => turnNavigation.getByRole('button').count(), { timeout: 15_000 })
-      .toBeGreaterThan(initialTurnCount)
-    const stableFirstTurnButton = turnNavigation.getByRole('button', { name: firstTurnLabel })
-    expect(await stableFirstTurnButton.evaluate(button => (
-      button.parentElement?.style.getPropertyValue('--turn-position') ?? ''
-    ))).not.toBe(firstTurnPosition)
-    await stableFirstTurnButton.focus()
-    await expect.poll(() => preview.textContent(), { timeout: 5_000 })
-      .toContain(FIXTURE.markers.user(firstTurn))
-    expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn))
-    await stableFirstTurnButton.press('Enter')
-    await expect.poll(() => stableFirstTurnButton.getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
+    // Paging converts marks to their loaded form without moving the
+    // fixed-pitch ladder.
+    await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
+    expect(await firstTurnButton.evaluate(button => (
+      button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
+    ))).toBe(firstTurnPosition)
+    // Activating the still-unloaded oldest mark pages the rest in and lands
+    // on the turn's own row.
+    await firstTurnButton.focus()
+    await firstTurnButton.press('Enter')
+    const firstLoaded = turnNavigation.getByRole('button', { name: 'Jump to turn 1', exact: true })
+    await firstLoaded.waitFor({ timeout: 60_000 })
+    await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
     await expect.poll(
-      () => page.locator(`[data-chat-turn="${String(firstTurn)}"][data-chat-flow-kind="user"]`).count(),
+      () => page.locator('[data-chat-turn="1"][data-chat-flow-kind="user"]').count(),
       { timeout: 5_000 },
     ).toBe(1)
 
@@ -311,9 +312,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 +331,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' })
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 2664ef0f01..37996ef301 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -42,6 +42,7 @@ const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
 const TOOL_READY_FILE = '.chat-scroll-tool-ready'
 const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
 const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
+const RAIL_SESSION_ID = 'chat-scroll-rail-e2e'
 const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
 const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
 const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
@@ -557,6 +558,68 @@ describe('web e2e: long Chat scroll contract', () => {
     })
   }, 180_000)
 
+  it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => {
+    await withScrollWorld({
+      failureShot: 'web-e2e-turn-rail-jump',
+      seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
+    }, async (world) => {
+      await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
+      await expectBottom(world.page)
+
+      // The whole-log outline reaches the rail before any paging: one mark
+      // per fixture turn, the oldest still in its load-and-jump form.
+      const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
+      await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 })
+        .toBe(HISTORY_FIXTURE.turns)
+      const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
+      expect(await firstUnloaded.count()).toBe(1)
+      // Fixed pitch: the ladder keeps its natural height, scrolls inside the
+      // frame, and (following the active tail mark) fades its upper end.
+      expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height')))
+        .toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`)
+      const railScroller = rail.locator('[class*="scroller"]')
+      await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
+      await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
+
+      // Activate the unloaded mark by keyboard: pointer input belongs to the
+      // rail frame, while each mark is the keyboard/AT destination. Focus
+      // first shows the outline-backed preview: prompt and settled response
+      // both travel ahead of the events.
+      const beforeRows = await loadedFlowRows(world.page)
+      await firstUnloaded.focus()
+      const tooltip = world.page.getByRole('tooltip')
+      await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1)
+      expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
+      expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1))
+      await world.page.keyboard.press('Enter')
+
+      // The jump pages history in and lands on turn 1: its mark flips to the
+      // loaded label and becomes current, the window grew, and the turn-1
+      // user row sits at the reading line.
+      const firstLoaded = rail.getByRole('button', { name: 'Jump to turn 1', exact: true })
+      await expect.poll(() => firstLoaded.count(), { timeout: 60_000 }).toBe(1)
+      await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
+      expect(await loadedFlowRows(world.page)).toBeGreaterThan(beforeRows)
+      // Drop mark focus so its hover/focus preview (which echoes the prompt
+      // marker) leaves the DOM before the transcript count below.
+      await firstLoaded.evaluate((el) => { (el as HTMLElement).blur() })
+      await expect.poll(() => world.page.getByRole('tooltip').count(), { timeout: 15_000 }).toBe(0)
+      await nextPaint(world.page)
+      const marker = world.page.locator('[data-conversation-scroll]')
+        .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false })
+      expect(await marker.count()).toBe(1)
+      const scrollport = await world.page.locator('[data-conversation-scroll]').boundingBox()
+      const row = await marker.boundingBox()
+      if (scrollport === null || row === null) throw new Error('turn-1 row or scrollport has no layout box')
+      expect(row.y - scrollport.y).toBeGreaterThanOrEqual(0)
+      expect(row.y - scrollport.y).toBeLessThanOrEqual(160)
+      // The rail followed the landing to the ladder top, so the fade now
+      // marks the other (downward) end.
+      await expect.poll(() => rail.locator('[class*="fadeBottom"]').count(), { timeout: 15_000 }).toBe(1)
+      assertClean(world)
+    })
+  }, 180_000)
+
   it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
     await withScrollWorld({
       failureShot: 'web-e2e-chat-scroll-live-tool',
diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts
index ed6c80da68..5f59aa48e5 100644
--- a/apps/web/tests/chat-scroll-fixture.ts
+++ b/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')
 }
diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts
index c43b919e5d..564334b1f3 100644
--- a/apps/web/tests/complex-history.perf.ts
+++ b/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')
 }
diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts
index 7f0bd5c37f..d3a3823799 100644
--- a/apps/web/tests/goal-command-presentation.e2e.ts
+++ b/apps/web/tests/goal-command-presentation.e2e.ts
@@ -113,7 +113,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)
diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts
index b99f7f90d4..da82648c50 100644
--- a/apps/web/tests/markdown-cjk-strong.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts
index 54783b48a5..a888469339 100644
--- a/apps/web/tests/markdown-images.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts
index be3e30a380..d1da29922a 100644
--- a/apps/web/tests/markdown-inline-code-links.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts
index 77e583bf45..28e98a89a6 100644
--- a/apps/web/tests/markdown-wide-table.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts
index c5e3e22a60..020d4b2a3a 100644
--- a/apps/web/tests/math-rendering.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts
index b63e155d9d..19f829d5ac 100644
--- a/apps/web/tests/minimal-preset.snapshot.ts
+++ b/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')
diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts
index 9e1772cdce..ca8fafc63e 100644
--- a/apps/web/tests/produced-file-mentions.e2e.ts
+++ b/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,
     })),
diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts
index 0761639de5..5494a934e3 100644
--- a/apps/web/tests/produced-files.e2e.ts
+++ b/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,
     })),
     '',
diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts
index f8614e9ae7..dc84191180 100644
--- a/apps/web/tests/reference-composer.e2e.ts
+++ b/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')
 }
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 1991ce2e0b..c0fe365775 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -803,7 +803,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(record)),
+    ...packChunkRuns(session.snapshotEvents()).map(record => JSON.stringify(record)),
     '',
   ].join('\n')
 }
@@ -848,7 +848,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]
diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts
index 7dfe576560..72b98105aa 100644
--- a/apps/web/tests/schedule-after.e2e.ts
+++ b/apps/web/tests/schedule-after.e2e.ts
@@ -1,6 +1,6 @@
 /** Keyless assembled-Web evidence for conversational Schedule delivery. */
 
-import { readFile } from 'node:fs/promises'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -29,6 +29,7 @@ import {
   type WebScaffold,
 } from './scaffold.ts'
 import {
+  REPO_ROOT,
   connectFreshWorkspace,
   conversationContextKey,
   saveFailureShot,
@@ -204,7 +205,7 @@ async function waitForReply(
 ): Promise> {
   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
@@ -454,7 +455,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)
@@ -469,7 +470,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'
@@ -492,7 +493,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)
 
@@ -515,7 +516,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)
@@ -540,12 +541,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'
@@ -559,7 +560,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
@@ -708,18 +709,34 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     const catalog = page.getByRole('list', { name: 'Active reminders' })
     await catalog.waitFor({ timeout: 10_000 })
     expect(await catalog.getByRole('listitem').count()).toBe(3)
-    const lightLayout = await catalog.evaluate((element) => {
-      const box = element.getBoundingClientRect()
+    const lightLayout = await page.evaluate(() => {
+      const triggerElement = document.querySelector('button[aria-label="3 reminders"]')
+      const catalogElement = document.querySelector('[aria-label="Active reminders"]')
+      if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) {
+        throw new Error('active reminder trigger or catalog is not mounted')
+      }
+      const triggerBox = triggerElement.getBoundingClientRect()
+      const catalogBox = catalogElement.getBoundingClientRect()
+      const viewport = window.innerWidth
       return {
-        width: box.width,
-        right: box.right,
-        viewport: window.innerWidth,
+        bodyPortal: catalogElement.parentElement === document.body,
+        position: getComputedStyle(catalogElement).position,
+        triggerLeft: triggerBox.left,
+        catalogLeft: catalogBox.left,
+        catalogRight: catalogBox.right,
+        width: catalogBox.width,
+        viewport,
+        expectedLeft: Math.min(Math.max(16, triggerBox.left), viewport - catalogBox.width - 16),
         scrollWidth: document.documentElement.scrollWidth,
-        background: getComputedStyle(element).backgroundColor,
+        background: getComputedStyle(catalogElement).backgroundColor,
       }
     })
+    expect(lightLayout.bodyPortal).toBe(true)
+    expect(lightLayout.position).toBe('fixed')
     expect(lightLayout.width).toBe(336)
-    expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport)
+    expect(lightLayout.catalogLeft).toBe(lightLayout.expectedLeft)
+    expect(lightLayout.catalogLeft).toBeLessThan(lightLayout.triggerLeft)
+    expect(lightLayout.catalogRight).toBeLessThanOrEqual(lightLayout.viewport - 16)
     expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport)
     expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)')
     const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' })
@@ -760,6 +777,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     }))
     expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight)
 
+    const evidenceDir = join(REPO_ROOT, '.artifacts')
+    await mkdir(evidenceDir, { recursive: true })
+    await writeFile(
+      join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.json'),
+      `${JSON.stringify(lightLayout, null, 2)}\n`,
+    )
+    await page.screenshot({
+      path: join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.png'),
+      fullPage: true,
+    })
+
     await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
     const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor)
     expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)')
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
index 368beeb9e1..a55958353d 100644
--- a/apps/web/tests/seeded-history.e2e.ts
+++ b/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}`)
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index f86f5c0964..f3be2fcd39 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -144,7 +144,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()
@@ -160,11 +160,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' }],
@@ -181,7 +181,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' }],
diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts
index 4e74eb8764..6d65cdca0d 100644
--- a/apps/web/tests/streaming-fence-highlight.e2e.ts
+++ b/apps/web/tests/streaming-fence-highlight.e2e.ts
@@ -24,28 +24,48 @@ const MODE = webSnapshotMode()
 const PROVIDER = 'streaming-fence-highlight-test'
 const MODEL = 'streaming-fence'
 const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
-const OPEN_REPLY = '```ts\nconst first: number = 1\nconst second = "two"\nlet tail'
+const FIRST_REPLY = '```ts\nconst first: number = 1\n'
+const OPEN_REPLY = `${FIRST_REPLY}const second = "two"\nlet tail`
 const REPLY = `${OPEN_REPLY}\n\`\`\``
 
-/** Deterministic model response held after the visible fence body arrives. */
+/** Deterministic model response held after each visible fence-growth frame. */
 class StreamingFenceAdapter extends LlmAdapter {
-  private resolvePaused!: () => void
-  private resolveContinuation!: () => void
-  private continued = false
-  readonly paused = new Promise((resolve) => { this.resolvePaused = resolve })
-  private readonly continuation = new Promise((resolve) => { this.resolveContinuation = resolve })
+  private resolveFirstPaused!: () => void
+  private resolveFirstContinuation!: () => void
+  private resolveSecondPaused!: () => void
+  private resolveSecondContinuation!: () => void
+  private firstContinued = false
+  private secondContinued = false
+  readonly firstPaused = new Promise((resolve) => { this.resolveFirstPaused = resolve })
+  readonly secondPaused = new Promise((resolve) => { this.resolveSecondPaused = resolve })
+  private readonly firstContinuation = new Promise((resolve) => { this.resolveFirstContinuation = resolve })
+  private readonly secondContinuation = new Promise((resolve) => { this.resolveSecondContinuation = resolve })
+
+  grow(): void {
+    if (this.firstContinued) return
+    this.firstContinued = true
+    this.resolveFirstContinuation()
+  }
+
+  finish(): void {
+    if (this.secondContinued) return
+    this.secondContinued = true
+    this.resolveSecondContinuation()
+  }
 
   continue(): void {
-    if (this.continued) return
-    this.continued = true
-    this.resolveContinuation()
+    this.grow()
+    this.finish()
   }
 
   override async *stream(options: GenerateOptions): AsyncIterable {
     yield { type: 'block-start', index: 0, blockType: 'text' }
-    yield { type: 'text-delta', index: 0, text: OPEN_REPLY }
-    this.resolvePaused()
-    await this.continuation
+    yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
+    this.resolveFirstPaused()
+    await this.firstContinuation
+    yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
+    this.resolveSecondPaused()
+    await this.secondContinuation
     if (options.signal?.aborted === true) throw options.signal.reason
     yield { type: 'text-delta', index: 0, text: '\n```' }
     yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
@@ -115,12 +135,26 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
     const settled = scaffold.whenTurnSettled(30_000)
     await writeComposerDraft(page, input, PROMPT)
     await input.press('Enter')
-    await adapter.paused
+    await adapter.firstPaused
 
     const streaming = page.locator('[data-streaming="true"]')
     await streaming.waitFor({ timeout: 10_000 })
     const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
     await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
+    await block.evaluate((element) => {
+      element.setAttribute('data-stream-block-retained', 'true')
+      element.querySelector('pre.shiki')?.setAttribute('data-stream-pre-retained', 'true')
+      element.querySelector('pre.shiki .line')?.setAttribute('data-stream-line-retained', 'true')
+    })
+
+    adapter.grow()
+    await adapter.secondPaused
+    await expect.poll(() => block.locator('pre.shiki .line').count()).toBe(3)
+    expect(await block.evaluate(element => ({
+      block: element.getAttribute('data-stream-block-retained'),
+      pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
+      line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
+    }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
     const midTree = await fenceTree(block)
     expect(midTree.language).toBe('ts')
     expect(midTree.lines).toHaveLength(3)
@@ -133,12 +167,17 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
       MODE,
     )
 
-    adapter.continue()
+    adapter.finish()
     await settled
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
     const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
     await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
     expect(await fenceTree(settledBlock)).toEqual(midTree)
+    expect(await settledBlock.evaluate(element => ({
+      block: element.getAttribute('data-stream-block-retained'),
+      pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
+      line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
+    }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts
index 6f2ca01325..24e642c83a 100644
--- a/apps/web/tests/subagent-interrupt-ui.e2e.ts
+++ b/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.
diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts
index 082bc0802b..54b8bd1cc6 100644
--- a/apps/web/tests/subagent-interrupt.e2e.ts
+++ b/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
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 49cb7bb2c7..d21ab4b36c 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f
-config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7
+config-catalog.md: a800ed70a6cefaacd6d6e7258e21df6afd158adf
+config-catalog.zh.md: 52403557ccbc1596f58a0bd6e8f062343374366f
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ec077edd10..a800ed70a6 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3083,7 +3083,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-Source: [`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts)
 
 
 
@@ -3353,6 +3353,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
 - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — requires `sessionProjections` ([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index d349575cb2..52403557cc 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3085,7 +3085,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-来源:[`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts)
+来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts)
 
 
 
@@ -3355,6 +3355,7 @@ export interface Config {
 - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
 - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — 需要 `sessionProjections`([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 3cbda7334b..1cd1407b59 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 610fc99df3d8ff6484ff0c3a6215d6ed1b697537
-module-graph.zh.md: 18043cb59155f5414aec32aa471182ba7fdc1e71
+module-graph.md: a569292470a3dadb0b44f6b4c17824c9f23755fa
+module-graph.zh.md: 7f10d7af160b8a179ed6284f7ed287492933defe
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 610fc99df3..a569292470 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -296,6 +296,7 @@ flowchart TD
     pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
     pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
     pkg_session_title_llm["session-title-llm"]
+    pkg_session_turn_outline["session-turn-outline"]
   end
   subgraph group_settings["packages/settings"]
     pkg_settings["settings"]
@@ -461,6 +462,9 @@ flowchart TD
   pkg_session_stats --> pkg_llm
   pkg_session_stats --> pkg_session
   pkg_session_stats --> pkg_session_projection
+  pkg_session_turn_outline --> pkg_llm
+  pkg_session_turn_outline --> pkg_session
+  pkg_session_turn_outline --> pkg_session_projection
   pkg_settings_file --> pkg_atomic_write
   pkg_settings_file --> pkg_home_paths
   pkg_settings_file --> pkg_settings
@@ -1269,6 +1273,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings) |
 | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 18043cb591..7f10d7af16 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -298,6 +298,7 @@ flowchart TD
     pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
     pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
     pkg_session_title_llm["session-title-llm"]
+    pkg_session_turn_outline["session-turn-outline"]
   end
   subgraph group_settings["packages/settings"]
     pkg_settings["settings"]
@@ -463,6 +464,9 @@ flowchart TD
   pkg_session_stats --> pkg_llm
   pkg_session_stats --> pkg_session
   pkg_session_stats --> pkg_session_projection
+  pkg_session_turn_outline --> pkg_llm
+  pkg_session_turn_outline --> pkg_session
+  pkg_session_turn_outline --> pkg_session_projection
   pkg_settings_file --> pkg_atomic_write
   pkg_settings_file --> pkg_home_paths
   pkg_settings_file --> pkg_settings
@@ -1271,6 +1275,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings) |
 | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 9016b73278..c5520d4dd2 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: a15b796e78de525874f8c28867e6748ba8779db8
-persistence-catalog.zh.md: f98485343e73b1bc777f6517a05cade49c77a15f
+persistence-catalog.md: fd5b3a4a377069fb7b1fbc37b78d11ec037073a4
+persistence-catalog.zh.md: 9d8fc88a44da1620e2de9aa066b9d6c6204b29b1
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index a15b796e78..fd5b3a4a37 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -189,7 +189,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter
  * The session's approval policy was switched — log-only, durable,
  * replayable, never in the model transcript (the model learns the policy
  * from the runtime-context snapshot and live switch notices). The LAST
- * such event is the session's override ({@link effectiveApprovalPolicy}).
+ * such event is the session's override.
  * `source: 'delegation'` marks an override seeded into a child; an absent
  * source is a runtime switch.
  */
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index f98485343e..9d8fc88a44 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -191,7 +191,7 @@ export type SessionEvent = {
  * The session's approval policy was switched — log-only, durable,
  * replayable, never in the model transcript (the model learns the policy
  * from the runtime-context snapshot and live switch notices). The LAST
- * such event is the session's override ({@link effectiveApprovalPolicy}).
+ * such event is the session's override.
  * `source: 'delegation'` marks an override seeded into a child; an absent
  * source is a runtime switch.
  */
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 03ac31e8c1..1cad3ee1f0 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 8d40a54762d6f9f90a594ede5951f17747be30af
-session-projection.zh.md: 763798cb8d745fc7b2838f9a2e769973613c824c
+session-projection.md: b5bacc4846a9a9709bb8c102aaa78810a2752110
+session-projection.zh.md: 36653721a125ceef13bff8b1a4562e7981a45696
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 8d40a54762..b5bacc4846 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -47,7 +47,10 @@ interface ProjectionDefinition<
     /** Validates the wire payload before it leaves the host. */
     viewSchema: ZodType
     /**
-     * State → wire payload (the read-side projection).
+     * State → wire payload (the read-side projection). The live drive keeps
+     * the two latest raw results and compares them with `Object.is`; an
+     * object-valued view must reuse its reference to suppress publication
+     * across internal-only state changes.
      * @param state - the current state.
      * @returns the whole current value for this unit's key.
      */
@@ -83,9 +86,9 @@ interface ProjectionSnapshot {
 
 ```ts type-equiv
 /**
- * Change-feed listener: one unit's value changed for one session. `value` is
- * the schema-validated `view` output; `seq` is the unit's watermark at
- * emission (the seq of the event that caused the change).
+ * Change-feed listener: one unit's raw `view` result changed by `Object.is`
+ * for one session. `value` is the schema-validated output; `seq` is the
+ * unit's watermark at emission (the seq of the event that caused the change).
  */
 type ProjectionChangeListener = (
   session: Session,
@@ -95,7 +98,7 @@ type ProjectionChangeListener = (
 ) => void
 ```
 
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes.
 
 ## The registry: `ctx.sessionProjections`
 
@@ -177,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
 
 ### `ctx.sessionProjections` — `SessionProjectionRegistry`
 
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
 
 ```ts cordis-catalog
 /**
@@ -201,7 +204,7 @@ register< K extends Exclude void
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 763798cb8d..36653721a1 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -47,7 +47,10 @@ interface ProjectionDefinition<
     /** Validates the wire payload before it leaves the host. */
     viewSchema: ZodType
     /**
-     * State → wire payload (the read-side projection).
+     * State → wire payload (the read-side projection). The live drive keeps
+     * the two latest raw results and compares them with `Object.is`; an
+     * object-valued view must reuse its reference to suppress publication
+     * across internal-only state changes.
      * @param state - the current state.
      * @returns the whole current value for this unit's key.
      */
@@ -83,9 +86,9 @@ interface ProjectionSnapshot {
 
 ```ts type-equiv
 /**
- * Change-feed listener: one unit's value changed for one session. `value` is
- * the schema-validated `view` output; `seq` is the unit's watermark at
- * emission (the seq of the event that caused the change).
+ * Change-feed listener: one unit's raw `view` result changed by `Object.is`
+ * for one session. `value` is the schema-validated output; `seq` is the
+ * unit's watermark at emission (the seq of the event that caused the change).
  */
 type ProjectionChangeListener = (
   session: Session,
@@ -95,7 +98,7 @@ type ProjectionChangeListener = (
 ) => void
 ```
 
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。
 
 ## 注册表:`ctx.sessionProjections`
 
@@ -177,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
 
 ### `ctx.sessionProjections` — `SessionProjectionRegistry`
 
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
 
 ```ts cordis-catalog
 /**
@@ -201,7 +204,7 @@ register< K extends Exclude void
diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index 8ad0ddf475..6998ba0cbe 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/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: d35395fb80a6558e15b82cdb76559f59e492f425
-session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b
+session.md: fbeb501b9ba6283a41c108fc07fc8787ed9e68a4
+session.zh.md: daf25d74bc5bd039a7d8b1a1882c2ec197136ce3
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index d35395fb80..fbeb501b9b 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -388,7 +388,7 @@ 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;
   /**
    * Create a detached session by validating and snapshotting borrowed seed
@@ -410,12 +410,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.
    */
-  get events(): readonly SessionEvent[];
+  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.
+   */
+  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;
   /**
@@ -462,7 +470,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.
    */
@@ -584,7 +592,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).
 
@@ -622,7 +630,7 @@ resolveAgent(sessionId: SessionId): Promise
  * @param signal - optional caller cancellation for persistence reads.
  * @returns the current attached state or persisted header and event prefix.
  */
-inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
+inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }>
 
 /**
  * Read all visible Session rows without resuming an Agent.
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index d3bc750b5c..daf25d74bc 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -390,7 +390,7 @@ 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;
   /**
    * Create a detached session by validating and snapshotting borrowed seed
@@ -412,12 +412,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.
    */
-  get events(): readonly SessionEvent[];
+  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.
+   */
+  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;
   /**
@@ -464,7 +472,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.
    */
@@ -588,7 +596,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)。
 
@@ -626,7 +634,7 @@ resolveAgent(sessionId: SessionId): Promise
  * @param signal - optional caller cancellation for persistence reads.
  * @returns the current attached state or persisted header and event prefix.
  */
-inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
+inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }>
 
 /**
  * Read all visible Session rows without resuming an Agent.
diff --git a/package.json b/package.json
index 768d491cb1..d947add909 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "license": "MIT",
   "private": true,
   "type": "module",
diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json
index d93327095f..888a445294 100644
--- a/packages/acp/acp/package.json
+++ b/packages/acp/acp/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp",
   "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts
index 99cd3695fc..25fc940a06 100644
--- a/packages/acp/acp/tests/approval.spec.ts
+++ b/packages/acp/acp/tests/approval.spec.ts
@@ -68,10 +68,13 @@ describe('ACP machine permission policy', () => {
   it('delegates a same-id foreign agent', async () => {
     harness = await makeBridgeHarness()
     const request = await ownedRequest()
+    const events = [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }]
     const foreign = {
       session: {
         id: request.agent.session.id,
-        events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }],
+        seq: events.length,
+        eventAt: (seq: number) => events[seq],
+        snapshotEvents: () => events,
         append: () => ({}),
       },
     } as unknown as Agent
diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts
index 85125b3661..55e56a25b0 100644
--- a/packages/acp/acp/tests/bridge.spec.ts
+++ b/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 () => {
diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts
index 8a44856590..c44ee3ab2e 100644
--- a/packages/acp/acp/tests/turns.spec.ts
+++ b/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' } })
   })
 
diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json
index 10048465e6..db6163c18e 100644
--- a/packages/api/gateway/package.json
+++ b/packages/api/gateway/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-gateway",
   "description": "Typert Remote Host dispatcher and Client API endpoint",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts
index 9f0d5d4cab..3a36e97ecd 100644
--- a/packages/api/gateway/src/stream-server.ts
+++ b/packages/api/gateway/src/stream-server.ts
@@ -19,11 +19,13 @@ export type RemoteStreamOpener = (
 /** Convert an invocation or carrier failure to a stable wire value. */
 export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
 
+const MAX_MISSED_HEARTBEATS = 2
+
 /** Own the no-server WebSocket acceptor and every active logical stream. */
 export class RemoteStreamMuxServer {
   private readonly server = new WebSocketServer({ noServer: true })
   private readonly connections = new Set>()
-  private readonly heartbeatAlive = new WeakMap()
+  private readonly missedHeartbeats = new WeakMap()
   private heartbeatTimer: NodeJS.Timeout | undefined
 
   /**
@@ -45,8 +47,8 @@ export class RemoteStreamMuxServer {
    */
   handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
     this.server.handleUpgrade(req, socket, head, (websocket) => {
-      this.heartbeatAlive.set(websocket, true)
-      websocket.on('pong', () => { this.heartbeatAlive.set(websocket, true) })
+      this.missedHeartbeats.set(websocket, 0)
+      websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) })
       this.startHeartbeat()
       const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
       const done = connection.run()
@@ -75,11 +77,16 @@ export class RemoteStreamMuxServer {
     this.heartbeatTimer = setInterval(() => {
       for (const socket of this.server.clients) {
         if (socket.readyState !== WebSocket.OPEN) continue
-        if (this.heartbeatAlive.get(socket) === false) {
-          socket.terminate()
+        const missed = this.missedHeartbeats.get(socket) as number
+        if (missed >= MAX_MISSED_HEARTBEATS) {
+          setImmediate(() => {
+            if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) {
+              socket.terminate()
+            }
+          })
           continue
         }
-        this.heartbeatAlive.set(socket, false)
+        this.missedHeartbeats.set(socket, missed + 1)
         socket.ping()
       }
     }, this.heartbeatIntervalMs)
diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts
index e73c76f8c0..6c2cc1da30 100644
--- a/packages/api/gateway/tests/stream-server.host.spec.ts
+++ b/packages/api/gateway/tests/stream-server.host.spec.ts
@@ -50,7 +50,7 @@ describe('Remote stream mux server carrier lifecycle', () => {
     await closed
   })
 
-  it('terminates a socket that does not answer the previous heartbeat', async () => {
+  it('requires two missed heartbeats before terminating an unresponsive socket', async () => {
     const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
     const client = await connect(entry.url)
     const serverSocket = acceptedSocket(entry.mux)
@@ -58,10 +58,39 @@ describe('Remote stream mux server carrier lifecycle', () => {
     const terminated = vi.spyOn(serverSocket, 'terminate')
     const closed = once(client, 'close')
 
+    await once(client, 'ping')
+    await once(client, 'ping')
+    expect(terminated).not.toHaveBeenCalled()
     await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() })
     await closed
   })
 
+  it('keeps the socket when a delayed Pong arrives before the final check', async () => {
+    const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20)
+    const client = await connect(entry.url, false)
+    const serverSocket = acceptedSocket(entry.mux)
+    const terminated = vi.spyOn(serverSocket, 'terminate')
+    let finalCheck: (() => void) | undefined
+    const immediate = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => {
+      finalCheck = callback
+      return 0 as unknown as NodeJS.Immediate
+    })
+
+    try {
+      await once(client, 'ping')
+      await once(client, 'ping')
+      await vi.waitFor(() => { expect(finalCheck).toBeDefined() })
+      serverSocket.emit('pong', Buffer.alloc(0))
+      finalCheck?.()
+      expect(terminated).not.toHaveBeenCalled()
+    } finally {
+      immediate.mockRestore()
+      const closed = once(client, 'close')
+      client.close()
+      await closed
+    }
+  })
+
   it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
     const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
 
@@ -223,8 +252,8 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000):
   return entry
 }
 
-async function connect(url: string): Promise {
-  const socket = new WebSocket(url)
+async function connect(url: string, autoPong = true): Promise {
+  const socket = new WebSocket(url, { autoPong })
   await once(socket, 'open')
   return socket
 }
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json
index e97970adff..a08895b357 100644
--- a/packages/api/remotes/package.json
+++ b/packages/api/remotes/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-remotes",
   "description": "Remote BFF assembly for application-selected Host capabilities",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts
index fd95221825..352178c781 100644
--- a/packages/api/remotes/tests/built-lib.e2e.ts
+++ b/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()
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 499198fb98..9e96e16e09 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: d1ddd25ad889013834654b8b78a35ec46a7c1f74
-README.zh.md: d4f8e68e358e265e073f9838616618140c22138b
+README.md: b3d22a340fff6a49270de520a043a1c8dfdbf096
+README.zh.md: 45590a53db17b32cc59f6107957c284b53ee8302
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index d1ddd25ad8..b3d22a340f 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory
 
 Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index d4f8e68e35..45590a53db 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -27,7 +27,7 @@ kind: "package-reference"
 
 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 
diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json
index a7891a8350..fc1de04248 100644
--- a/packages/api/session-controller/package.json
+++ b/packages/api/session-controller/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-session-controller",
   "description": "Session Remote commands, cold reads, and live control transport",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts
index 02214bb872..71cc72e0ab 100644
--- a/packages/api/session-controller/src/client/contract/session.ts
+++ b/packages/api/session-controller/src/client/contract/session.ts
@@ -119,6 +119,15 @@ export interface ISession {
    * @returns completion; failures land in snapshot.openState/loadingOlder.
    */
   loadOlder(): Promise
+  /**
+   * Page history backwards until the window covers `seq` (inclusive) — the
+   * turn-jump loader. Repeated calls while a jump is paging lower its shared
+   * target and return the in-flight completion; `snapshot.loadingOlder` is
+   * the busy signal for the whole jump.
+   * @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
+   * @returns completion once covered, exhausted, superseded, or failed soft.
+   */
+  loadThrough(seq: number): Promise
   /**
    * Execute one slash-command line against this session's agent — pure
    * admission semantics (the host executor durably logs the lifecycle).
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index 25d5e61082..6e33412cf9 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -38,6 +38,9 @@ import { SessionQueueMirror } from './queue-mirror.ts'
 /** Messages requested per history page. */
 export const PAGE_MESSAGES = 50
 
+/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
+export const JUMP_PAGE_MESSAGES = 200
+
 /** Manager-owned observers of a Session object's local state edges. */
 export interface SessionOptions {
   /** Catalog-discovered address selecting non-activating subagent transport. */
@@ -78,6 +81,10 @@ export class Session implements SessionFace {
    *  passes drop all writes once the generation moves on. */
   private openGeneration = 0
   private loadingOlder = false
+  /** Shared low-water target of the running jump loop; null when no jump is paging. */
+  private jumpTargetSeq: number | null = null
+  /** The running jump loop's completion, shared by retargeting callers. */
+  private jumpPromise: Promise | null = null
   /** Authoritative stream-only inbox snapshot; pending work never hits history. */
   private readonly queueMirror = new SessionQueueMirror()
   private running = false
@@ -369,6 +376,52 @@ export class Session implements SessionFace {
     }
   }
 
+  /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
+  loadThrough(seq: number): Promise {
+    if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve()
+    if (this.jumpPromise !== null) {
+      // Retarget the running loop to the lowest requested seq.
+      this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq)
+      return this.jumpPromise
+    }
+    // A plain single-page pull owns the busy flag; the jump does not queue
+    // behind it (the caller retries once it settles) and must leave no
+    // target behind — only the loop's finally clears that field, and no
+    // loop starts here.
+    if (this.loadingOlder) return Promise.resolve()
+    this.jumpTargetSeq = seq
+    this.loadingOlder = true
+    this.notifier.markDirty()
+    // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the
+    // stream generation; this pass then stops instead of paging the new
+    // generation toward its old target.
+    const generation = this.openGeneration
+    this.jumpPromise = (async () => {
+      try {
+        while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
+          if (generation !== this.openGeneration) return
+          const events = this.events
+          if (events === undefined) return
+          const before = this.baseSeq
+          await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
+          // No-progress guard: an empty or dropped page that still claims more
+          // history must end the loop, not spin it.
+          if (this.baseSeq >= before) return
+        }
+      } catch (error) {
+        if (!isRemoteFailure(error)) {
+          console.error('[session-controller] loadThrough failed:', error)
+        }
+      } finally {
+        this.jumpTargetSeq = null
+        this.jumpPromise = null
+        this.loadingOlder = false
+        this.notifier.markDirty()
+      }
+    })()
+    return this.jumpPromise
+  }
+
   /** Rebuild an opened history source after address replacement.
    *  Invalidates any in-flight open first; queue state belongs to the independently
    *  reconnecting control stream and remains untouched. */
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 0988660659..701aedcc36 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -48,7 +48,7 @@ import type {
 interface SessionReadState {
   readonly id: SessionId
   readonly header: SessionHeader
-  readonly events: SessionEvent[]
+  readonly events: readonly SessionEvent[]
 }
 
 /** Implements Session business commands delegated by the Session Controller Remote service. */
@@ -473,7 +473,7 @@ export class SessionCommandController {
   private async readSessionState(sessionId: SessionId): Promise {
     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 }
diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts
index 106381be88..7264e8cb82 100644
--- a/packages/api/session-controller/src/history.ts
+++ b/packages/api/session-controller/src/history.ts
@@ -113,7 +113,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)
       for (let index = suffix.length - 1; index >= 0; index -= 1) {
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index 572f895754..4a103dbd22 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -191,10 +191,10 @@ export class SessionController extends TypertRemoteService {
   inspect(
     sessionId: SessionId,
     signal?: AbortSignal,
-  ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
+  ): Promise<{ meta: SessionHeader; events: readonly 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)
   }
diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts
index 6bfea1609d..922e0c8e2c 100644
--- a/packages/api/session-controller/tests/agent.host.spec.ts
+++ b/packages/api/session-controller/tests/agent.host.spec.ts
@@ -377,7 +377,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
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index bda4b6c962..20c374e8c3 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -325,7 +325,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)
 
diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts
index 86fb18c16f..958e53f5ba 100644
--- a/packages/api/session-controller/tests/session-fork.host.spec.ts
+++ b/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,
diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
index d42639922e..90b5851e08 100644
--- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts
+++ b/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 {
diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts
index 8a31e9208a..c2ac3216a5 100644
--- a/packages/api/session-controller/tests/session-projections.host.spec.ts
+++ b/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)
   })
@@ -538,7 +538,7 @@ describe('Session control projection frames', () => {
     return frames
   }
 
-  it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => {
+  it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())
     const proxy = remote(ctx)
@@ -554,6 +554,7 @@ describe('Session control projection frames', () => {
     now.mockReturnValue(200)
     session.append('turn/start', { turn: 1 })
     now.mockReturnValue(300)
+    // The equal payload is a new object, so Object.is still treats its view as changed.
     seedMessages(session, 1)
     now.mockRestore()
 
diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts
index 08d2056d54..60987608b9 100644
--- a/packages/api/session-controller/tests/session-rename.host.spec.ts
+++ b/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' } })
   })
diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts
index 319c315873..899a3f7745 100644
--- a/packages/api/session-controller/tests/session.client.spec.ts
+++ b/packages/api/session-controller/tests/session.client.spec.ts
@@ -5,7 +5,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
+import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts'
 import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
 import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
 
@@ -254,6 +254,145 @@ describe('paging', () => {
     }
   })
 
+  it('loadThrough pages repeatedly until the window covers the target seq', async () => {
+    const oldest = plainTurn(0, 0, '最旧问', '最旧答')
+    const middle = plainTurn(6, 1, '中问', '中答')
+    const newest = plainTurn(12, 2, '新问', '新答')
+    const { api, session } = makeSession()
+    api.onHistory = (payload) => {
+      if (payload.beforeSeq === undefined) return histResponse(newest, true)
+      return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
+    }
+    await session.open()
+
+    const gate = deferred>>()
+    api.onHistory = (payload) => {
+      api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
+      void payload
+      return gate.promise
+    }
+    const jump = session.loadThrough(0)
+    expect(session.getSnapshot().loadingOlder).toBe(true)
+    gate.resolve(ok(historyValue(middle, true)))
+    await jump
+    const snapshot = session.getSnapshot()
+    expect(snapshot.loadingOlder).toBe(false)
+    expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
+    expect(api.callsOf('session.history')).toMatchObject([
+      { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES },
+      { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES },
+    ])
+  })
+
+  it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => {
+    const { api, session } = makeSession()
+    await session.loadThrough(0) // cold: no-op
+    expect(api.calls).toEqual([])
+    api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
+    await session.open()
+    const calls = api.calls.length
+    await session.loadThrough(6) // baseSeq is already 6
+    await session.loadThrough(9) // inside the window
+    expect(api.calls.length).toBe(calls)
+  })
+
+  it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => {
+    const oldest = plainTurn(0, 0, 'a', 'b')
+    const middle = plainTurn(6, 1, 'c', 'd')
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true)
+    await session.open()
+
+    const gate = deferred>>()
+    api.onHistory = () => {
+      api.onHistory = () => histResponse(oldest, false)
+      return gate.promise
+    }
+    const first = session.loadThrough(6)
+    const second = session.loadThrough(0)
+    gate.resolve(ok(historyValue(middle, true)))
+    await Promise.all([first, second])
+    expect(eventSeqs(session)).toEqual([...oldest, ...middle].map(event => event.seq).concat([12, 13, 14, 15, 16, 17]))
+    expect(api.callsOf('session.history')).toHaveLength(2)
+  })
+
+  it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => {
+    const middle = plainTurn(6, 1, 'c', 'd')
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true)
+    await session.open()
+
+    // A plain single-page pull holds the busy flag while the jump is refused.
+    const gate = deferred>>()
+    api.onHistory = () => gate.promise
+    const older = session.loadOlder()
+    await session.loadThrough(0) // refused: must not park seq 0 anywhere
+    gate.resolve(ok(historyValue(middle, true)))
+    await older
+
+    // A later jump to a nearer seq pages exactly to it — a leaked 0 target
+    // would keep pulling three-event pages all the way to the head.
+    api.onHistory = (payload) => {
+      const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3
+      return histResponse(
+        [ev.user(start, `u${String(start)}`), ev.user(start + 1, `u${String(start + 1)}`), ev.user(start + 2, `u${String(start + 2)}`)],
+        start > 0,
+      )
+    }
+    await session.loadThrough(4)
+    // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a
+    // third call at beforeSeq 3 and pull the head to 0.
+    expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq))
+      .toEqual([12, 6])
+    expect(eventSeqs(session)[0]).toBe(3)
+  })
+
+  it('loadThrough stops paging when the event stream generation moves mid-loop', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+    await session.open()
+
+    const gate = deferred>>()
+    api.onHistory = () => gate.promise
+    const jump = session.loadThrough(0)
+    // The address is rebuilt while the first page is in flight.
+    api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+    const rebuilt = session.resync()
+    gate.resolve(ok(historyValue(plainTurn(6, 1, 'c', 'd'), true)))
+    await jump
+    await rebuilt
+    // The stale loop must not page the new generation toward its old target:
+    // history calls are the gated page and the resync tail only.
+    expect(api.callsOf('session.history')).toHaveLength(1)
+    expect(session.getSnapshot().loadingOlder).toBe(false)
+  })
+
+  it('loadThrough stops on a page that makes no progress instead of looping', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = payload => payload.beforeSeq === undefined
+      ? histResponse(plainTurn(12, 2, 'x', 'y'), true)
+      : histResponse([], true) // empty page still claiming more history
+    await session.open()
+    await session.loadThrough(0)
+    expect(session.getSnapshot().loadingOlder).toBe(false)
+    expect(api.callsOf('session.history')).toHaveLength(1)
+  })
+
+  it('loadThrough fails soft on a thrown page and clears its busy state', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+    await session.open()
+    api.onHistory = () => Promise.reject(new Error('page wire down'))
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      await session.loadThrough(0)
+      expect(errorSpy).toHaveBeenCalled()
+      expect(session.getSnapshot().loadingOlder).toBe(false)
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
   it('ignores loadOlder while one is in flight (single request)', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts
index e2c50c88a7..91bdcb67ee 100644
--- a/packages/api/session-controller/tests/transport.host.spec.ts
+++ b/packages/api/session-controller/tests/transport.host.spec.ts
@@ -34,6 +34,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,
@@ -164,12 +174,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 } })
 
@@ -195,7 +203,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(),
@@ -293,10 +301,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({ code: 'gateway/internal' })
   })
 
diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json
index 1f6f33b8c9..f8a3526f75 100644
--- a/packages/api/settings-controller/package.json
+++ b/packages/api/settings-controller/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-settings-controller",
   "description": "Remote owner for the configuration surfaces over the settings-domain seams",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json
index c2291a758c..dda1e924fb 100644
--- a/packages/api/workspace-controller/package.json
+++ b/packages/api/workspace-controller/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-controller",
   "description": "Workspace Remote commands and reconnect-safe state transport",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json
index 819798b369..edc9ce41ef 100644
--- a/packages/attachment/attachment-local/package.json
+++ b/packages/attachment/attachment-local/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment-local",
   "description": "Private content-addressed DSH_HOME attachment storage",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json
index 295200d97e..3f4735b18b 100644
--- a/packages/attachment/attachment/package.json
+++ b/packages/attachment/attachment/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment",
   "description": "Durable immutable attachment storage seam for the DeepSeek Harness",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json
index a154de4465..cf2674fd0f 100644
--- a/packages/boot/app-boot/package.json
+++ b/packages/boot/app-boot/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-app-boot",
   "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json
index 2e48f6f95c..8bf6253142 100644
--- a/packages/boot/cmdline/package.json
+++ b/packages/boot/cmdline/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-cmdline",
   "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json
index 0676d473e6..7ac571cb98 100644
--- a/packages/bundle/acp-app/package.json
+++ b/packages/bundle/acp-app/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp-app",
   "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index 3539f9d5f9..eeca3abd86 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-base",
   "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json
index a82163bb26..5d64116544 100644
--- a/packages/bundle/headless/package.json
+++ b/packages/bundle/headless/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-headless",
   "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts
index 22e2578838..5f2ad0e4b7 100644
--- a/packages/bundle/headless/src/index.ts
+++ b/packages/bundle/headless/src/index.ts
@@ -17,7 +17,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-default-model'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { assertNever } from '@deepseek-ai/dsh-util-values'
-import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionId } 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'
@@ -60,12 +60,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
@@ -197,7 +201,7 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise {
     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`)
diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts
index fb86ff8387..bc788682bf 100644
--- a/packages/bundle/headless/tests/headless.spec.ts
+++ b/packages/bundle/headless/tests/headless.spec.ts
@@ -310,6 +310,21 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
+  it('fails when an event below the captured Session length cannot be read', async () => {
+    const test = await bench({
+      afterPrompt(session, message) {
+        appendTurn(session, 1, message, 'unreachable', true)
+        Object.defineProperty(session, 'eventAt', { value: () => undefined })
+      },
+    })
+    expect(await test.run()).toMatchObject({
+      code: 1,
+      out: '',
+      err: 'dsh: headless summary cannot read seq 0 below captured length 7\n',
+    })
+    await test.ctx.fiber.dispose()
+  })
+
   it('reports a direct Agent creation failure', async () => {
     const ctx = new Context()
     let err = ''
diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json
index be32c83a1c..6be7d3e3ff 100644
--- a/packages/bundle/sdk-app/package.json
+++ b/packages/bundle/sdk-app/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-app",
   "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json
index e3215aff4d..1e9444dae7 100644
--- a/packages/bundle/sdk-minimal/package.json
+++ b/packages/bundle/sdk-minimal/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-minimal",
   "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 15a5df12cc..79c62a6e20 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -72,6 +72,11 @@
     - id: session-stats
       name: '@deepseek-ai/dsh-session-stats'
 
+    # Whole-log turn outline for the chat turn rail (the turnOutline
+    # projection key): every turn stays navigable before its events page in.
+    - id: session-turn-outline
+      name: '@deepseek-ai/dsh-session-turn-outline'
+
     # Resolve bind host, SSH launch, and display once at boot, then mount the
     # matching dual-face directory picker. Mount -native or -browse directly in
     # an overlay to pin the interaction.
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 31af35aebf..0063e7758b 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-app",
   "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
@@ -102,6 +102,7 @@
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-log-export": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json
index 3be8f43c03..954f034ab6 100644
--- a/packages/client/connection/package.json
+++ b/packages/client/connection/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-connection",
   "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts
index 3020c80326..11a7f882b8 100644
--- a/packages/client/connection/src/client/connection.ts
+++ b/packages/client/connection/src/client/connection.ts
@@ -278,8 +278,7 @@ export class ConnectionController {
           this.callSink(() => { this.sinks.onConnected?.(host) })
         }
       } catch {
-        // Transport failure: treat as generation failure, then enter the shared retry path.
-        if (!ac.signal.aborted) ac.abort()
+        // Source settlement and controller cancellation already abort the generation.
       }
 
       await failed
@@ -306,12 +305,12 @@ export class ConnectionController {
   }
 }
 
-/** Await source readiness without letting a stalled carrier wedge startup forever. */
+/** Await source readiness while reporting, but not cancelling, a slow Host. */
 function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise {
   return new Promise((resolve, reject) => {
     let settled = false
     const timeout = setTimeout(() => {
-      finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) })
+      console.warn(`[connection] generation is still not ready after ${String(timeoutMs)}ms`)
     }, timeoutMs)
     const aborted = (): void => {
       finish({ error: new Error('connection generation aborted', { cause: signal.reason }) })
diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts
index 3b9432995f..681f5ac242 100644
--- a/packages/client/connection/tests/connection.client.spec.ts
+++ b/packages/client/connection/tests/connection.client.spec.ts
@@ -568,7 +568,8 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('rejects and retries a generation whose source never reports ready', async () => {
+  it('reports but retains a generation whose source is slow to report ready', async () => {
+    vi.useFakeTimers()
     const source = new FakeGenerationSource()
     source.suppressReady = true
     let connected = 0
@@ -580,13 +581,16 @@ describe('connection lifecycle', () => {
     )
     controller.start()
     try {
-      await Promise.resolve()
+      await vi.advanceTimersByTimeAsync(0)
       expect(source.activeCount).toBe(1)
-      await new Promise(resolve => setTimeout(resolve, 45))
+      await vi.advanceTimersByTimeAsync(20)
       expect(connected).toBe(0)
+      expect(source.activeCount).toBe(1)
+      expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms')
     } finally {
       controller.stop()
       warnSpy.mockRestore()
+      vi.useRealTimers()
     }
   })
 
diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json
index 85aff120ad..44435ef224 100644
--- a/packages/client/hmr/package.json
+++ b/packages/client/hmr/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
   "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json
index c5b6172739..739ab8110b 100644
--- a/packages/client/locale/package.json
+++ b/packages/client/locale/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-locale",
   "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json
index ec09842019..d0672fa18d 100644
--- a/packages/client/modules/package.json
+++ b/packages/client/modules/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
   "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/store/package.json b/packages/client/store/package.json
index 412120ec94..e3d64fcc0c 100644
--- a/packages/client/store/package.json
+++ b/packages/client/store/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-store",
   "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json
index c2e09d5f25..8a1d5540f3 100644
--- a/packages/client/ui-agent-preset/package.json
+++ b/packages/client/ui-agent-preset/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-agent-preset",
   "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json
index 1ae4c8557e..d28080371f 100644
--- a/packages/client/ui-approval/package.json
+++ b/packages/client/ui-approval/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-approval",
   "description": "Approval composer takeover over the scoped Remote Event waterfall",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json
index c37b238bfb..d92faee87b 100644
--- a/packages/client/ui-attachment/package.json
+++ b/packages/client/ui-attachment/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
   "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json
index 5ff4d38d40..c0b457b135 100644
--- a/packages/client/ui-brand-official/package.json
+++ b/packages/client/ui-brand-official/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
   "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index f3977d734f..7df0e3ba3e 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: f0742573648df734f579dd8d75e7387ee8957902
-README.zh.md: d2fcd2241078f1bf66c1c93b0a85eaf46be0f7cc
+README.md: 19cfcf4d14f7e805d6113eeb4e34a586f7150574
+README.zh.md: 18e20eeea15cf3eeb5e8e4a777fa534251232a81
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index f074257364..19cfcf4d14 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -15,6 +15,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
 - [System prompt row](#system-prompt-row)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
+- [Scroll ownership](#scroll-ownership)
 - [Model Experience](#model-experience)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
 - [Dev Note](#dev-note)
@@ -42,6 +43,13 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
 
 -----
 
+
+## Scroll ownership
+
+Chat restores semantic anchors across history prepend and renderer remounts. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn ([loaded-Turn navigation](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md)).
+
+-----
+
 
 ## Model Experience
 
@@ -55,7 +63,8 @@ None; Chat presentation does not assemble or mutate provider requests.
 
 
 
-- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation likewise represents only loaded Turns; loading an earlier page preserves existing Turn marks and redistributes the complete loaded set in a compact rail without an unloaded-history placeholder. Marks stay 10px apart until the loaded set exceeds the available height, then compress to fit.
+- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
+- **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then.
 
 
 
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index d2fcd22410..18e20eeea1 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -15,6 +15,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
 - [系统提示词行](#system-prompt-row)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
+- [滚动归属](#scroll-ownership)
 - [模型体验](#model-experience)
 - [已知限制与暂缓事项](#known-limitations-and-deferred-work)
 - [开发备注](#dev-note)
@@ -42,6 +43,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
 
 -----
 
+
+## 滚动归属
+
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn([已加载 Turn 导航](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md))。
+
+-----
+
 
 ## 模型体验
 
@@ -55,7 +63,8 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
 
 
 
-- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航同样只表示已加载的 Turn;加载更早一页时,已有 Turn 刻度保持身份不变,完整的已加载集合在紧凑轨道中重新排布,不显示未加载历史占位。刻度默认相隔 10px,仅在已加载集合超过可用高度时压缩间距。
+- **transcript 只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的 Turn 与宿主 `turnOutline` 投影合并,每个已开始的 Turn 都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该 Turn 的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载 Turn。
+- **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。
 
 
 
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index 124bf10f88..e94269815a 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-chat",
   "description": "Chat Conversation target, node definitions, renderers, and details surface",
-  "version": "0.1.2-alpha.2",
+  "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
   },
@@ -73,6 +73,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index be061dff13..a23ada43d7 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -125,6 +125,7 @@ export function apply(ctx: Context): void {
             if (!result.ok) throw new Error(`path open failed: ${result.error.message}`)
           },
           loadOlder: () => { void session.loadOlder() },
+          loadThrough: seq => session.loadThrough(seq),
           loadImage: Object.assign(
             (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
             { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index b2b07d4229..42aada1017 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -7,10 +7,11 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
-import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts'
+import type { ChatSnapshot } from '../contract/snapshot.ts'
 import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
 import { TurnNavigator } from './TurnNavigator.tsx'
+import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts'
 import { formatRunDuration } from './message-chrome.ts'
 import css from './ChatView.module.css'
 
@@ -202,8 +203,8 @@ function TurnStatus({ startTime, t }: {
  * ordered business Node crosses the keyed renderer seat.
  */
 export function ChatView({
-  useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
-  fileMentions, useTranscriptView, t,
+  useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough,
+  loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
   const nodeStore = useChat(s => s.nodes)
@@ -211,6 +212,13 @@ export function ChatView({
   // both the data and its change signal: the array identity moves only when a
   // Turn enters, leaves, or changes its preview.
   const turnNavigationItems = useChat(s => s.navigation.items())
+  // Host-computed whole-log outline; the merge is view-layer only (the
+  // conversation snapshot never carries projection values).
+  const turnOutline = useProjection('turnOutline')
+  const railItems = useMemo(
+    () => mergeTurnRailItems(turnNavigationItems, turnOutline),
+    [turnNavigationItems, turnOutline],
+  )
   const timeline = useChat(s => s.timeline)
   const inbox = useSession(s => s.queue)
   // Workspace root off the session list row: path summaries display relative to it.
@@ -295,6 +303,15 @@ export function ChatView({
   /** Paging anchor: semantic row/position at click, updated by reader scrolls
    * while the request is pending and restored after the prepend lands. */
   const anchorRef = useRef(null)
+  /** Unloaded-turn jump in flight: target turn plus its load-through seq. */
+  const pendingJumpRef = useRef<{ turn: number; seq: number } | null>(null)
+  /** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */
+  const jumpLandedRef = useRef(false)
+  const [busyJumpTurn, setBusyJumpTurn] = useState(null)
+  /** Bumped when a loadThrough completion settles, after its last page's commit. */
+  const [jumpSettleTick, setJumpSettleTick] = useState(0)
+  /** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */
+  const jumpRepageHeadRef = useRef(null)
   const firstSeqRef = useRef(null)
   const openedRef = useRef(false)
   const lastKeyRef = useRef(null)
@@ -321,6 +338,11 @@ export function ChatView({
       return
     }
     const el = scrollerOf(local)
+    if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
+      const latest = turnNavigationItems.at(-1)?.turn ?? first.turn
+      setActiveTurn(current => current === latest ? current : latest)
+      return
+    }
     const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2)
     const reading = turnAtLine(local, readingLine)
     // No row reaches the line yet: the flow head still owns the mark. Otherwise
@@ -333,9 +355,6 @@ export function ChatView({
         next = item.turn
       }
     }
-    if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
-      next = turnNavigationItems.at(-1)?.turn ?? next
-    }
     setActiveTurn(current => current === next ? current : next)
   }, [turnNavigationItems])
 
@@ -367,6 +386,9 @@ export function ChatView({
 
   const toBottom = (el: HTMLElement): void => {
     anchorRef.current = null
+    // Returning to the live tail supersedes a jump still landing.
+    pendingJumpRef.current = null
+    setBusyJumpTurn(current => current === null ? current : null)
     el.scrollTop = el.scrollHeight
     observedTopRef.current = el.scrollTop
     atBottomRef.current = true
@@ -375,6 +397,57 @@ export function ChatView({
     setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null)
   }
 
+  // Land a row at the reading line and republish scroll-derived state. A
+  // latest-ref, so navigateToTurn's identity stays stable for the memoized rail.
+  const landOnRowRef = useRef<(local: HTMLElement, el: HTMLElement, row: HTMLElement, turn: number) => void>(
+    () => {},
+  )
+  landOnRowRef.current = (local, el, row, turn) => {
+    el.scrollTop += flowTop(row, el) - 24
+    observedTopRef.current = el.scrollTop
+    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+    atBottomRef.current = isAtBottom
+    setAtBottom(isAtBottom)
+    setActiveTurn(turn)
+    const position = isAtBottom ? null : scrollPosition(local, el)
+    if (isAtBottom) chatScroll.save(null)
+    else if (position !== null) chatScroll.save(position)
+  }
+
+  /**
+   * Land the pending jump once its Turn has a rendered anchor row; false
+   * while it must keep waiting. Mid-jump landings (`settle` false) keep the
+   * jump armed with the target row as the paging anchor, so later chunks and
+   * the load-earlier button's unmount re-land on the same row; the settling
+   * call clears the jump.
+   */
+  const realizePendingJump = (local: HTMLElement, el: HTMLElement, settle: boolean): boolean => {
+    const pending = pendingJumpRef.current
+    if (pending === null) return true
+    const item = railItems.find(candidate => candidate.turn === pending.turn)
+    if (item === undefined || item.anchor.kind !== 'loaded') return false
+    const row = anchorElement(local, item.anchor.key)
+    if (row === null) return false
+    if (settle) {
+      pendingJumpRef.current = null
+      setBusyJumpTurn(null)
+      const held = anchorRef.current
+      const landedEarlier = jumpLandedRef.current
+      jumpLandedRef.current = false
+      anchorRef.current = null
+      // A reader who moved off an already-landed target mid-jump keeps their
+      // place; a first landing, or an untouched one, takes the correction.
+      if (!landedEarlier || held?.key === item.anchor.key) {
+        landOnRowRef.current(local, el, row, pending.turn)
+      }
+      return true
+    }
+    landOnRowRef.current(local, el, row, pending.turn)
+    jumpLandedRef.current = true
+    anchorRef.current = { key: item.anchor.key, top: flowTop(row, el) }
+    return true
+  }
+
   useLayoutEffect(() => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
@@ -416,6 +489,11 @@ export function ChatView({
       const row = anchorElement(local, anchor.key)
       if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
       observedTopRef.current = el.scrollTop
+      // A jump chunk lands here: scroll to the target once its rows exist;
+      // until then keep holding the reader's row for the next chunk.
+      if (!realizePendingJump(local, el, false) && row !== null) {
+        anchorRef.current = { key: anchor.key, top: flowTop(row, el) }
+      }
       firstSeqRef.current = firstSeq
       /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
       lastKeyRef.current = lastKey
@@ -437,7 +515,13 @@ export function ChatView({
     followSigRef.current = followSig
     // Follow new flow content while pinned; do NOT re-pin on every render
     // merely because atBottomRef is true (scroll threshold → setState → snap).
-    if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el)
+    if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) {
+      toBottom(el)
+      return
+    }
+    // A jump whose target committed outside the anchored-prepend path (for
+    // example after a mid-jump toBottom dropped the held anchor) lands here.
+    if (pendingJumpRef.current !== null) realizePendingJump(local, el, false)
   })
 
   const onScrollRef = useRef(() => {})
@@ -531,6 +615,53 @@ export function ChatView({
     if (!loadingOlder) anchorRef.current = null
   }, [loadingOlder])
 
+  // Jump settlement: every loadThrough completion bumps the tick after its
+  // last page's commit, and a plain pull's loadingOlder flip re-settles a
+  // jump it made wait. A still-pending jump is realized now, held while a
+  // plain load-earlier pull owns the pager (its completion retries below),
+  // repaged once per head movement, or landed on the nearest rendered Turn
+  // at or after the target (failure, exhausted history, or a Turn with no
+  // visible row).
+  useEffect(() => {
+    const pending = pendingJumpRef.current
+    const local = listRef.current
+    if (pending === null || local === null) return
+    const el = scrollerOf(local)
+    // The settling landing runs after the load-earlier button's unmount
+    // commit, so the target row cannot drift once the jump clears.
+    if (realizePendingJump(local, el, true)) return
+    const uncovered = firstSeq === null || firstSeq > pending.seq
+    if (uncovered && hasMore) {
+      // A plain pull owns the pager right now: hold the jump (busy stays)
+      // instead of degrading to a wrong landing.
+      if (loadingOlder) return
+      if (jumpRepageHeadRef.current !== firstSeq) {
+        jumpRepageHeadRef.current = firstSeq
+        const held = pagingAnchor(local, el)
+        if (held !== null && held.dataset.chatAnchorKey !== undefined) {
+          anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+        }
+        void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
+        return
+      }
+    }
+    for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) {
+      const turn = Number(row.dataset.chatTurn)
+      if (!Number.isSafeInteger(turn) || turn < pending.turn) continue
+      landOnRowRef.current(local, el, row, turn)
+      break
+    }
+    pendingJumpRef.current = null
+    setBusyJumpTurn(null)
+    // Snapshot values are read at settle time; the completion tick is the trigger.
+  }, [jumpSettleTick])
+
+  // A jump held while a plain pull owned the pager waits in the effect
+  // above; the pull's completion is its retry signal.
+  useEffect(() => {
+    if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick(tick => tick + 1)
+  }, [loadingOlder])
+
   const loadOlderAnchored = (): void => {
     const local = listRef.current
     /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
@@ -548,35 +679,51 @@ export function ChatView({
   }
 
   // Identity feeds the memoized rail; a fresh closure per render would defeat it.
-  const navigateToTurn = useCallback((item: TurnNavigationItem): void => {
+  const navigateToTurn = useCallback((item: TurnRailItem): void => {
     const local = listRef.current
     if (local === null) return
-    const row = anchorElement(local, item.anchorKey)
-    if (row === null) return
     const el = scrollerOf(local)
-    el.scrollTop += flowTop(row, el) - 24
-    observedTopRef.current = el.scrollTop
+    if (item.anchor.kind === 'unloaded') {
+      // Jumping into history is leaving the live tail: release bottom
+      // ownership on the click itself, or the pinned-scroll snap (a
+      // non-reader scroll delivery during the first prepend's compensation)
+      // would call toBottom and cancel the jump.
+      atBottomRef.current = false
+      setAtBottom(false)
+      // Hold the reader's place through the paging chunks; the layout effect
+      // lands on the target once its rows commit.
+      const held = pagingAnchor(local, el)
+      if (held !== null && held.dataset.chatAnchorKey !== undefined) {
+        anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+      }
+      pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq }
+      jumpRepageHeadRef.current = null
+      jumpLandedRef.current = false
+      setBusyJumpTurn(item.turn)
+      void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
+      return
+    }
+    const row = anchorElement(local, item.anchor.key)
+    if (row === null) return
+    // A loaded-mark click supersedes any jump still landing.
+    pendingJumpRef.current = null
+    setBusyJumpTurn(current => current === null ? current : null)
+    landOnRowRef.current(local, el, row, item.turn)
     // A pending older page still has to compensate the prepended height, so
     // navigation moves that anchor to the new position instead of dropping it.
     const landed = loadingOlder ? pagingAnchor(local, el) : null
     anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined
       ? null
       : { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) }
-    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
-    atBottomRef.current = isAtBottom
-    setAtBottom(isAtBottom)
-    setActiveTurn(item.turn)
-    const position = isAtBottom ? null : scrollPosition(local, el)
-    if (isAtBottom) chatScroll.save(null)
-    else if (position !== null) chatScroll.save(position)
-  }, [loadingOlder, chatScroll])
+  }, [loadingOlder, loadThrough])
 
   return (
     
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 76a7b73e9c..5e771e6727 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -8,7 +8,7 @@ pointer-events: none; } -.rail { +.frame { /* The band a reader actually sees: the scrollport minus the sticky composer stack covering its floor. ConversationRoot publishes both measurements on the scrollport; the fallbacks carry the first paint before its observer @@ -24,9 +24,9 @@ padding, so the rail gives that inset back and keeps 12px of its own. */ right: calc(12px - (var(--dsh-composer-side-clearance) + 16px)); width: 28px; - /* Never taller than the band it centers in: a short window (a tall composer, - a low viewport) shrinks the rail instead of pushing marks under the - composer or above the scrollport. */ + /* Fixed-pitch marks never compress: a ladder taller than the band scrolls + inside this frame instead of pushing marks under the composer or above + the scrollport. */ height: min( var(--turn-natural-height), max(0px, calc(var(--turn-rail-band) - 64px)), @@ -38,14 +38,46 @@ transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } -.marks { +/* The frame's inner scroller: no visible scrollbar, no scroll chaining into + the transcript, and gradient fades over the ends that can still scroll. */ +.scroller { position: absolute; - inset: var(--turn-rail-inset) 0; + inset: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} + +.scroller::-webkit-scrollbar { + display: none; +} + +.fadeTop { + mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%); +} + +.fadeBottom { + mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%); +} + +.fadeTop.fadeBottom { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 24px, + #000 calc(100% - 24px), + transparent 100% + ); +} + +.marks { + position: relative; + height: var(--turn-natural-height); } .markPosition { position: absolute; - top: min(var(--turn-natural-position), var(--turn-position)); + top: calc(var(--turn-natural-position) + var(--turn-rail-inset)); right: 0; left: 0; height: 10px; @@ -83,11 +115,21 @@ transition: width 140ms ease, background-color 140ms ease; } +/* Ordered before hover/active so those states keep their stronger tick. */ +.markUnloaded::before { + width: 8px; + opacity: 0.6; +} + .markPreview::before { width: 18px; background: var(--dsw-alias-label-tertiary); } +.markBusy::before { + animation: dsh-turn-mark-busy 1s ease-in-out infinite; +} + .markActive::before { width: 20px; background: var(--dsw-alias-label-primary); @@ -108,13 +150,14 @@ .preview { position: absolute; - /* Centered on its mark (mark positions are measured inside the rail inset), - then held clear of both rail ends. */ + /* Centered on its mark. Mark positions live in the scrolled ladder, so the + frame-level preview subtracts the scroller's offset, then holds clear of + both frame ends. */ top: clamp( 0px, calc( - min(var(--turn-natural-position), var(--turn-position)) - + var(--turn-rail-inset) - var(--turn-preview-height) / 2 + var(--turn-natural-position) + var(--turn-rail-inset) + - var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2 ), calc(100% - var(--turn-preview-height)) ); @@ -143,14 +186,14 @@ .previewPrompt { font: var(--dsw-font-xs-strong-13); - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; } .previewResponse { margin-top: 4px; color: var(--dsw-alias-label-caption); font: var(--dsw-font-xxs-12); - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; } @keyframes dsh-turn-mark-enter { @@ -163,6 +206,12 @@ to { opacity: 1; transform: translateX(0); } } +@keyframes dsh-turn-mark-busy { + 0%, + 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + @container (max-width: 900px) { .slot { display: none; @@ -170,11 +219,14 @@ } @media (prefers-reduced-motion: reduce) { - .rail, + .frame, + .scroller, .markPosition, .mark::before, + .markBusy::before, .preview { transition: none; animation: none; + scroll-behavior: auto; } } diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index 55818f85cd..f56b3ab3f3 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,107 +1,198 @@ import { - memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useEffect, useId, useRef, useState, + type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { TurnNavigationItem } from '../contract/snapshot.ts' +import type { TurnRailItem } from './turn-rail-items.ts' import css from './TurnNavigator.module.css' interface TurnNavigatorProps { - readonly items: readonly TurnNavigationItem[] + readonly items: readonly TurnRailItem[] readonly activeTurn: number | null - readonly onNavigate: (item: TurnNavigationItem) => void + /** Turn whose jump is still paging history in; its mark pulses. */ + readonly busyTurn: number | null + readonly onNavigate: (item: TurnRailItem) => void readonly t: ChatViewSlotProps['t'] } -/** Resting gap between neighbouring marks before the rail compresses to fit. */ +/** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */ const TURN_SPACING_PX = 10 /** Rail padding above the first mark and below the last one, per end. */ const RAIL_INSET_PX = 6 +/** Fade band the mask reserves at a scrollable end. */ +const FADE_PX = 24 type TurnPositionStyle = CSSProperties & { readonly '--turn-natural-position': string - readonly '--turn-position': string } -type TurnRailStyle = CSSProperties & { +type TurnFrameStyle = CSSProperties & { readonly '--turn-natural-height': string readonly '--turn-rail-inset': string + readonly '--turn-scroll-top': string } -function itemPosition(index: number, count: number): TurnPositionStyle { - const ratio = count <= 1 ? 0 : index / (count - 1) - return { - '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px`, - '--turn-position': `${String(ratio * 100)}%`, - } +function itemPosition(index: number): TurnPositionStyle { + return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` } } -function railSize(count: number): TurnRailStyle { +function frameStyle(count: number, scrollTop: number): TurnFrameStyle { return { '--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`, '--turn-rail-inset': `${String(RAIL_INSET_PX)}px`, + '--turn-scroll-top': `${String(scrollTop)}px`, } } function itemAtPointer( - items: readonly TurnNavigationItem[], - rail: HTMLElement, + items: readonly TurnRailItem[], + frame: HTMLElement, + scrollTop: number, clientY: number, -): TurnNavigationItem | undefined { - const rect = rail.getBoundingClientRect() - const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX) - const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight)) - return items[Math.round(ratio * (items.length - 1))] +): TurnRailItem | undefined { + const rect = frame.getBoundingClientRect() + const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX + const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX))) + return items[index] } -function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +/** Scroll state the mask fades and follow logic read together. */ +interface RailScrollState { + readonly top: number + readonly canScrollUp: boolean + readonly canScrollDown: boolean +} + +const RAIL_AT_REST: RailScrollState = { top: 0, canScrollUp: false, canScrollDown: false } + +function railScrollState(scroller: HTMLElement): RailScrollState { + const top = scroller.scrollTop + return { + top, + canScrollUp: top > 1, + canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1, + } +} + +function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean { + return left.top === right.top + && left.canScrollUp === right.canScrollUp + && left.canScrollDown === right.canScrollDown +} + +function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) + const [scrollState, setScrollState] = useState(RAIL_AT_REST) + const scrollerRef = useRef(null) + /** While the pointer works the rail, follow must not move it under the hand. */ + const pointerInsideRef = useRef(false) const previewId = useId() + + const syncScrollState = (): void => { + const scroller = scrollerRef.current + if (scroller === null) return + const next = railScrollState(scroller) + setScrollState(current => sameRailScrollState(current, next) ? current : next) + } + + // Frame resizes (band/composer changes) move the overflow edges without a + // scroll event; item count changes move the content height the same way. + useEffect(() => { + const scroller = scrollerRef.current + if (scroller === null || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(syncScrollState) + observer.observe(scroller) + return () => { observer.disconnect() } + }, []) + useEffect(syncScrollState, [items.length]) + + // Keep the active mark visible: centre it whenever it leaves the scrollport, + // unless the reader's pointer is working the rail. + useEffect(() => { + const scroller = scrollerRef.current + const index = items.findIndex(item => item.turn === activeTurn) + if (scroller === null || index < 0 || pointerInsideRef.current) return + const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX + const viewTop = scroller.scrollTop + const viewHeight = scroller.clientHeight + if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return + const target = Math.max(0, markTop - viewHeight / 2) + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + if (typeof scroller.scrollTo === 'function') { + scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' }) + } else { + scroller.scrollTop = target + } + syncScrollState() + }, [activeTurn, items]) + if (items.length < 2) return null const previewIndex = items.findIndex(item => item.turn === previewTurn) const preview = previewIndex < 0 ? undefined : items[previewIndex] - const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex, items.length) + const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex) const previewAtPointer = (event: PointerEvent): void => { - setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null) + const scrollTop = scrollerRef.current?.scrollTop ?? 0 + setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null) } const navigateAtPointer = (event: MouseEvent): void => { - const item = itemAtPointer(items, event.currentTarget, event.clientY) + const scrollTop = scrollerRef.current?.scrollTop ?? 0 + const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY) if (item !== undefined) onNavigate(item) } + const fadeClasses = [css.scroller] + if (scrollState.canScrollUp) fadeClasses.push(css.fadeTop) + if (scrollState.canScrollDown) fadeClasses.push(css.fadeBottom) return (