Browse Source

Merge pull request #3029 from deepseek-harness/worktree-perfproj

perf(session): centralize cold observation and snapshot-first opening
imccyu 2 weeks ago
parent
commit
fc4c0a02eb
100 changed files with 3596 additions and 1900 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  2. 20 14
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  3. 20 14
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
  5. 201 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
  6. 201 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
  7. 1 0
      apps/cli/package.json
  8. 10 15
      apps/cli/tests/github-webhook-real.e2e.ts
  9. 3 0
      apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml
  10. 14 0
      apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts
  11. 3 18
      apps/cli/tests/web-agent-presets.e2e.ts
  12. 3 0
      apps/cli/tsconfig.json
  13. 11 2
      apps/web/tests/agent-preset-selection.e2e.ts
  14. 12 0
      apps/web/tests/cordis-tool-round.e2e.ts
  15. 8 3
      apps/web/tests/default-model.e2e.ts
  16. 2 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  17. 1 1
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  18. 1 1
      apps/web/tests/reference-composer.e2e.ts
  19. 16 25
      apps/web/tests/seeded-history.e2e.ts
  20. 1 1
      apps/web/tests/smoke-real.e2e.ts
  21. 39 67
      apps/web/tests/startup-auto-selection.e2e.ts
  22. 32 0
      apps/web/tests/subagent-conversation.e2e.ts
  23. 2 2
      docs/config-catalog.i18n.yaml
  24. 5 5
      docs/config-catalog.md
  25. 5 5
      docs/config-catalog.zh.md
  26. 2 2
      docs/event-producer-consumer.i18n.yaml
  27. 7 7
      docs/event-producer-consumer.md
  28. 7 7
      docs/event-producer-consumer.zh.md
  29. 2 2
      docs/module-graph.i18n.yaml
  30. 129 124
      docs/module-graph.md
  31. 129 124
      docs/module-graph.zh.md
  32. 2 2
      docs/persistence-catalog.i18n.yaml
  33. 17 1
      docs/persistence-catalog.md
  34. 17 1
      docs/persistence-catalog.zh.md
  35. 2 2
      docs/subsystems/persistence.i18n.yaml
  36. 11 0
      docs/subsystems/persistence.md
  37. 11 0
      docs/subsystems/persistence.zh.md
  38. 2 2
      docs/subsystems/session-projection.i18n.yaml
  39. 50 10
      docs/subsystems/session-projection.md
  40. 50 10
      docs/subsystems/session-projection.zh.md
  41. 2 2
      docs/subsystems/session-query.i18n.yaml
  42. 8 0
      docs/subsystems/session-query.md
  43. 8 0
      docs/subsystems/session-query.zh.md
  44. 2 2
      docs/subsystems/session.i18n.yaml
  45. 2 9
      docs/subsystems/session.md
  46. 2 9
      docs/subsystems/session.zh.md
  47. 2 2
      docs/subsystems/subagent.i18n.yaml
  48. 7 18
      docs/subsystems/subagent.md
  49. 7 18
      docs/subsystems/subagent.zh.md
  50. 2 2
      docs/subsystems/web-client.i18n.yaml
  51. 2 2
      docs/subsystems/web-client.md
  52. 2 2
      docs/subsystems/web-client.zh.md
  53. 59 61
      packages/api/gateway/src/client/journal-stream.ts
  54. 353 222
      packages/api/gateway/tests/journal-stream.client.spec.ts
  55. 1 1
      packages/api/remotes/src/client/index.ts
  56. 0 1
      packages/api/session-controller/package.json
  57. 158 45
      packages/api/session-controller/src/agent.ts
  58. 11 7
      packages/api/session-controller/src/catalog.ts
  59. 0 8
      packages/api/session-controller/src/client/contract/sessions.ts
  60. 5 1
      packages/api/session-controller/src/client/contract/snapshot.ts
  61. 0 2
      packages/api/session-controller/src/client/sessions/lineage.ts
  62. 29 29
      packages/api/session-controller/src/client/sessions/manager.ts
  63. 3 4
      packages/api/session-controller/src/client/sessions/projection-store.ts
  64. 0 11
      packages/api/session-controller/src/client/sessions/service.ts
  65. 9 6
      packages/api/session-controller/src/client/sessions/session.ts
  66. 25 11
      packages/api/session-controller/src/client/transport.ts
  67. 18 35
      packages/api/session-controller/src/commands.ts
  68. 7 7
      packages/api/session-controller/src/control.ts
  69. 101 98
      packages/api/session-controller/src/history.ts
  70. 37 17
      packages/api/session-controller/src/index.ts
  71. 93 112
      packages/api/session-controller/src/list.ts
  72. 83 0
      packages/api/session-controller/src/model-selection-projection.ts
  73. 59 21
      packages/api/session-controller/src/types.ts
  74. 143 27
      packages/api/session-controller/tests/agent.host.spec.ts
  75. 24 11
      packages/api/session-controller/tests/commands-create-fork.host.spec.ts
  76. 47 10
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  77. 23 12
      packages/api/session-controller/tests/control-jobs.host.spec.ts
  78. 118 4
      packages/api/session-controller/tests/controller.host.spec.ts
  79. 38 37
      packages/api/session-controller/tests/fake-api.client.ts
  80. 31 4
      packages/api/session-controller/tests/manager.client.spec.ts
  81. 154 62
      packages/api/session-controller/tests/session-cold.host.spec.ts
  82. 16 24
      packages/api/session-controller/tests/session-fork.host.spec.ts
  83. 12 11
      packages/api/session-controller/tests/session-history-journal.host.spec.ts
  84. 34 26
      packages/api/session-controller/tests/session-models.host.spec.ts
  85. 5 3
      packages/api/session-controller/tests/session-presets.host.spec.ts
  86. 128 47
      packages/api/session-controller/tests/session-projections.host.spec.ts
  87. 91 88
      packages/api/session-controller/tests/session-search.host.spec.ts
  88. 41 19
      packages/api/session-controller/tests/session.client.spec.ts
  89. 44 25
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  90. 82 4
      packages/api/session-controller/tests/test-remote.ts
  91. 58 32
      packages/api/session-controller/tests/transport.client.spec.ts
  92. 212 93
      packages/api/session-controller/tests/transport.host.spec.ts
  93. 2 1
      packages/api/session-controller/tsconfig.host.json
  94. 1 1
      packages/client/connection/src/client/api.ts
  95. 99 40
      packages/client/connection/src/client/fixture.ts
  96. 1 1
      packages/client/connection/src/client/index.ts
  97. 6 1
      packages/client/connection/tests/fake-api.client.ts
  98. 25 80
      packages/client/connection/tests/fixture.client.spec.ts
  99. 2 0
      packages/client/ui-agent-preset/package.json
  100. 5 1
      packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd
-2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49
+2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362
+2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e

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

@@ -134,7 +134,7 @@ If a page request is canceled with its physical carrier generation, the journal
 
 `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace.
 
-It owns Session list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control.
+It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `llm.models` because it is not Session-specific.
 
 The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner.
 
@@ -148,9 +148,9 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
 
 | Operation | Source or result without a live Agent | Activation rule |
 |---|---|---|
-| `session.list`, `search` | persistence, projection cache, or cold log | Never resumes an Agent |
+| `session.list`, `search` | headers and projection cache; a bounded small-log read can resolve uncertain blankness | Never resumes an Agent |
 | `session.page(address)` | attached Session or persistence log | Never resumes an Agent |
-| `session.follow(address)` | cold-read current cursor, then wait for future appends | Neither opening nor waiting resumes an Agent |
+| `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background |
 | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent |
 | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent |
 | `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state |
@@ -159,6 +159,12 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
 
 Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup.
 
+`SessionQuery.observeSession()` chooses an attached Session or borrows one prepared source from `SessionPersistence.borrowSession()`. The persistence preparation cache shares concurrent cold reads and pins the exact unpublished Session until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state.
+
+`session.list` never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata.
+
+`model/selection` is a required-on-read durable event because it changes the model route used by the next request. Its projection records both the last request selection and a later pending selection; prompt assembly consumes the pending value when the matching `request/header` is committed.
+
 #### Session journal
 
 `session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages.
@@ -167,20 +173,20 @@ The tail page also carries a projection baseline no later than `throughSeq`; old
 
 Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone.
 
-`session.follow` installs `session/event` and `session/created` listeners before checking an attached Session or persistence, then reads the current cursor.
+`session.follow` installs `session/event` and `session/created` listeners before observing an attached or prepared Session.
 
-The first follow response is `{ type: 'opened', cursor }`. A generation with `afterSeq` first replays the missing suffix from the authoritative log, then emits commits buffered during the read in sequence order.
+The first follow response is a complete `{ type: 'snapshot', header, cursor, events, hasMore, projections }` frame. Every reconnect sends another complete snapshot replacement; the protocol has no `afterSeq`. Events committed during observation remain buffered and are emitted after the snapshot in sequence order.
 
-A cold Session can open history immediately and keep follow waiting. Future events appear only after another explicit command resumes the Agent.
+A cold ordinary Session can publish its prepared snapshot immediately. After that first frame, the Controller transfers a retained observation to one background promotion; follow does not wait for activation. Direct-subagent addresses never use this promotion path.
 
-Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer first obtains opening cursor `C`, then calls `session.page({ throughSeq: C })`; entries `C + 1...` received during the read remain in the follow queue, and the page must cover exactly through `C` before the layer merges and publishes a continuous sequence.
+Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer validates and publishes the opening snapshot directly. It calls `session.page({ throughSeq })` only for older history or when a later event reveals a sequence gap.
 
 ```text
-ctx.remote.session.follow(address, afterSeq?) --------|
-                                                       |[]> SessionEventStream
-ctx.remote.session.page(address, throughSeq, pageArgs) -|    |-- replace(window)
-                                                            |-- prepend(history)
-                                                            `-- append(live entry)
+ctx.remote.session.follow(address, pageArgs) ----------------|
+  snapshot(header, cursor, page, projections), event*        |[]> SessionEventStream
+ctx.remote.session.page(address, throughSeq, pageArgs) -------|    |-- replace(window)
+                                                                  |-- prepend(history)
+                                                                  `-- append(live entry)
 ```
 
 Each Client Session owns only one current `events: SessionEventStream | undefined`. The read-only `SessionEventSource` gives the materialized event window to Conversation consumers.
@@ -328,7 +334,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources; the r
 
 `RemoteSnapshotStream` tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement.
 
-`RemoteJournalStream` tests pin follow-before-page, opening-overlap removal, contiguous append, historical prepend, reconnect catch-up, gap repair, and one atomic replacement.
+`RemoteJournalStream` tests pin snapshot-first opening, contiguous append, historical prepend, reconnect replacement, gap repair, and one atomic replacement.
 
 Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.
 
@@ -352,7 +358,7 @@ Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier
 
 ## Consequences
 
-The browser can read and follow a durable Session while its Agent is stopped. Observation does not implicitly resume execution; only explicitly authorized Session commands create or resume Agents according to their own rules.
+The browser can read a durable Session while its Agent is stopped. Opening an ordinary Session publishes the prepared snapshot before one background promotion begins; list, search, page, and other observation-only reads never activate it.
 
 Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another.
 

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

@@ -134,7 +134,7 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼
 
 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。
 
-它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。
+它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `llm.models` 公开,因为它不属于特定 Session。
 
 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。
 
@@ -148,9 +148,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
 
 | 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 |
 |---|---|---|
-| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent |
+| `session.list`、`search` | header 与投影缓存;可通过有界的小日志读取判断不确定的 blank 状态 | 永不恢复 Agent |
 | `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent |
-| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent |
+| `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 |
 | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent |
 | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
 | `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
@@ -159,6 +159,12 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
 
 读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。
 
+`SessionQuery.observeSession()` 选择 attached Session,或从 `SessionPersistence.borrowSession()` 借用 prepared source。Persistence preparation cache 共享并发冷读取,并在所有 observation lease 释放前固定同一个未发布 Session。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。
+
+`session.list` 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。
+
+`model/selection` 是 required-on-read 的持久 event,因为它改变下一次请求使用的 model route。对应 projection 同时记录最近一次 request selection 与之后的 pending selection;prompt assembly 在提交匹配的 `request/header` 时消费 pending value。
+
 #### Session 日志
 
 `session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。
@@ -167,20 +173,20 @@ tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只
 
 普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。
 
-`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。
+`session.follow` 在观察 attached 或 prepared Session 前先安装 `session/event` 与 `session/created` listener。
 
-首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit
+首次 follow 返回完整的 `{ type: 'snapshot', header, cursor, events, hasMore, projections }` frame。每次重连都发送另一份完整 snapshot replacement;协议不含 `afterSeq`。观察期间提交的 event 会保留在缓冲区,并在 snapshot 之后按 seq 发出
 
-冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现
+普通冷 Session 可以立即发布 prepared snapshot。首帧之后,Controller 把 retained observation 交给一次后台 promotion;follow 不等待激活。Direct-subagent 地址不会进入该 promotion 路径
 
-Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布
+Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层直接校验并发布 opening snapshot;仅在读取更早历史或后续 event 暴露 seq gap 时调用 `session.page({ throughSeq })`
 
 ```text
-ctx.remote.session.follow(address, afterSeq?) --------|
-                                                       |[]> SessionEventStream
-ctx.remote.session.page(address, throughSeq, pageArgs) -|    |-- replace(window)
-                                                            |-- prepend(history)
-                                                            `-- append(live entry)
+ctx.remote.session.follow(address, pageArgs) ----------------|
+  snapshot(header, cursor, page, projections), event*        |[]> SessionEventStream
+ctx.remote.session.page(address, throughSeq, pageArgs) -------|    |-- replace(window)
+                                                                  |-- prepend(history)
+                                                                  `-- append(live entry)
 ```
 
 每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。
@@ -328,7 +334,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、`$eve
 
 `RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。
 
-`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。
+`RemoteJournalStream` 测试固定 snapshot-first opening、连续 append、历史 prepend、重连 replacement、gap repair 与一次性 replacement。
 
 Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。
 
@@ -352,7 +358,7 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C
 
 ## 后果
 
-浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。
+浏览器可以在 Agent 停止时读取持久 Session。打开普通 Session 时先发布 prepared snapshot,再开始一次后台 promotion;list、search、page 及其他只读 observation 不会激活 Agent。
 
 持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md
+2026-08-25-session-observations-and-projection-owned-client-state.md: e47f2fc75ecbca51d01af077f6c6ab98f4e275f9
+2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 527a4eb6b6765cba95d6067f2be60bff8f31a559

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

@@ -0,0 +1,201 @@
+# Agent Note: Session observations and projection-owned client state
+
+Status: implemented
+
+English | [中文](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)
+
+## Problem
+
+Session-facing consumers needed the same logical data but resolved it independently. List, follow, page, attachment and fork reads, and subagent inspection each chose between an attached Session, persisted metadata, a prepared Session, and projection cache entries. One page visit could therefore materialize the same cold log more than once, and independently assembled header, event, cursor, and projection values could describe different cuts.
+
+Client features also kept Session-derived facts in several forms. Title had dedicated list and update handling; model selection mixed a Session-specific catalog request with local state; agent preset display could infer a global default before the current Session arrived; and subagent listing scanned or reconstructed identity separately. These mirrors introduced intermediate states in which the UI showed a guessed default, a raw id, or an unavailable state even though the durable Session already determined the answer.
+
+Unifying only the persistence read would leave those Client mirrors as competing authorities. Unifying only the Client fields would leave each Host endpoint free to obtain a different source cut. The read unit and the derived-state unit therefore need one coordinated ownership rule.
+
+## Decision
+
+Exact Session reads use a retained `SessionObservation`, and replayable Session-derived values exposed to the Client use registered projections. Observation owns source selection and one immutable read cut; projection owns derivation from that cut. API layers select what to publish, while Client code consumes finished values and does not reconstruct Session facts from events or duplicate them in domain-specific mirrors.
+
+### Data flow
+
+The two ownership rules meet at the observation's projection snapshot. Lightweight listing may stop at cached hints; every exact opening reaches the same observation path and gives the Client a complete replacement baseline.
+
+```mermaid
+flowchart LR
+  List["list / search"] --> Corpus["SessionQuery corpus"]
+  Follow["follow"] --> Observe["observeSession"]
+  Page["page / attachment / fork"] --> Observe
+  Subagent["subagent list / continuation"] --> Corpus
+  Subagent --> Observe
+  Corpus --> Cache["projection cache hints"]
+  Cache --> ClientList["Client Session list"]
+  Cache -->|"small miss"| Observe
+  Observe --> Source{"live or cold"}
+  Source --> Live["attached Session cut"]
+  Source --> Borrow["borrowSession"]
+  Borrow --> Prepared["SessionPreparations.borrow"]
+  Live --> Mode{"all or none"}
+  Prepared --> Mode
+  Mode --> Snapshot["SessionObservation"]
+  Snapshot --> Opening["follow opening snapshot"]
+  Snapshot --> Read["page / inspection"]
+  Opening --> Store["Client projection store"]
+  Store --> Domain["title / model / preset / subagent"]
+```
+
+### Observation is the point-read unit
+
+`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise `SessionPersistence.borrowSession()` and `SessionPreparations.borrow()` share and pin one prepared Session, including an in-flight cold load.
+
+Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold.
+
+### Source resolution and lifetime
+
+An observation binds all returned fields to one lifecycle witness. Callers do not combine a header from corpus listing, events from persistence, and projections from a later live Session. The selected header and event prefix produce the cursor and projection snapshot together.
+
+Live preference is checked both before and after a cold borrow. The second check closes the race in which an Agent attaches while persistence is loading. If persistence itself reports that a live source won but that source has already detached by the time SessionQuery examines it, resolution restarts instead of publishing an unowned reference.
+
+Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection.
+
+The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent.
+
+Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected.
+
+### Projection execution boundary
+
+For a live source, `all` reads one synchronous registry snapshot. For a prepared source, the projection cache may seed valid state rows, after which every registered unit advances over the exact remaining event prefix. The resulting client values share one `asOfSeq`.
+
+Filtering belongs after computation because it changes disclosure, not state. A page authorization check may consume only `subagent`, and a list row may publish only list-relevant values, while both still rely on a complete projected cut when they request projection work.
+
+The registry owns fold state; each domain owns its `init`, `apply`, `view`, schemas, and `stateVersion`. SessionQuery knows only whether projection work is required. It does not know title, model, preset, subagent, token, image, plan, todo, or goal semantics.
+
+`view` remains an uncached synchronous conversion over folded state. Its cost is bounded by the registered projection units and is paid at snapshot publication; introducing a second cache would add invalidation states without reducing event replay.
+
+Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list and subagent list first use live projection state or durable projection-cache rows. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints.
+
+`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered.
+
+### Read audiences
+
+Each public operation chooses one query and projection policy. The choice is part of that operation's behavior rather than a heuristic inside persistence or transport.
+
+| Operation | Read path | Projection policy | Agent activation |
+|---|---|---|---|
+| `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never |
+| `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never |
+| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery |
+| `session.page` | One exact observation | None, except projection-backed subagent authorization | Never |
+| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source |
+| Subagent list and continuation | Corpus plus live/cache/observation resolution | All on a cold fallback; audience consumes identity or inherited values | Never for listing; continuation follows its explicit command semantics |
+
+### Replayable Client facts are projection-owned
+
+A Client-visible fact belongs to `SessionProjectionMap` when its value is determined by the Session header or event log and must survive reload, cold access, or reconnect. The rule covers title, list metadata, model selection, agent preset selection, subagent identity, and subagent timing. Their domain packages own pure projection definitions; the Session transport and Client value store remain domain-neutral.
+
+The three projection delivery states have different meanings:
+
+- A Session-list hint is optional, partial, and possibly stale. A missing key means unknown, so a list consumer must not invent an empty value or deployment default.
+- A follow opening baseline is the complete set of client-visible projection capabilities registered at its cursor. A missing key there means the capability is absent for that Host composition.
+- An explicit `null` is a domain-computed no-value result. It is distinct from a missing list hint and survives JSON transport.
+
+These distinctions prevent one overloaded `undefined` from representing cache miss, unloaded plugin, and a real domain answer. API types name list data as hints and opening data as a baseline so a consumer cannot assume equivalent completeness merely because both carry projection values.
+
+### Client merge rules
+
+| Input | Completeness | Freshness | Meaning of missing key |
+|---|---|---|---|
+| Session list hints | Partial | Last durable checkpoint or bounded fallback cut | Unknown |
+| Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
+| Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
+
+The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence.
+
+The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority.
+
+The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row.
+
+Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict.
+
+Client-local interaction state also remains local: loading and error status, an open menu, an in-flight selection, and a staged choice for a not-yet-created Session are not replayable Session facts. Once a choice applies to a Session, its durable event and projection become authoritative.
+
+### Domain applications
+
+- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing.
+- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header.
+- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session.
+- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself.
+- **Subagent presentation.** Opening projection values establish timing and identity before the Client declares the child interactive or offline, so transport loading does not masquerade as a durable state.
+
+These migrations remove special-case Client state without making projection own provider catalogs or interaction mechanics. A domain still owns mutations and commands; projection owns only their replayable Session result.
+
+### Failure and readiness boundaries
+
+- A list cache miss is not an error and does not hide the row. Unknown hints remain absent until a bounded fallback or exact opening supplies them.
+- A projection failure during an exact cold observation makes that observation fail as corrupt Session data; callers do not publish a mixture of successful keys and failed keys.
+- A subagent candidate's failed cold observation is isolated to that candidate's diagnostic row; sibling candidates remain usable.
+- A catalog load failure is Client-visible catalog state. It does not erase a previously complete catalog during refresh and does not synthesize a Session selection.
+- A follow carrier generation is not accepted until its opening snapshot is validated and applied. The previous generation remains visible during reconnect.
+
+Cancellation stops queued or in-flight cold resolution at documented checkpoints and releases every acquired lease. Cancellation does not convert into not-found, nor may it leave a prepared entry pinned.
+
+### Ownership matrix
+
+| Concern | Owner | Non-owner |
+|---|---|---|
+| Cold materialization and revision checks | Session persistence | API Controller and Client |
+| Exact live-preferred read cut | SessionQuery observation | Individual endpoint helpers |
+| Fold state and client-value computation | Projection registry and domain unit | SessionQuery and Client |
+| Partial list acceleration | Projection cache and list policy | Follow protocol |
+| Opening and reconnect replacement | Session follow and journal stream | Session page |
+| Per-key value ordering | Client projection store | Domain UI components |
+| Provider or preset catalog lifecycle | Its catalog directory | Session projection |
+| Rendering and transient interaction state | Domain UI package | Host projection units |
+
+### Extension rules
+
+1. Determine whether a new value is a replayable fact of one Session. If it is, define or reuse its durable header/event input before adding a Client field.
+2. Register one pure projection unit in the owning domain. Keep fold state and Client view types distinct when their representations differ.
+3. Let exact readers request `projectionMode: 'all'`; filter only when constructing an audience-specific response.
+4. Let list consumers accept an optional hint. Do not force full corpus hydration merely to avoid an explicit unknown state.
+5. Feed the generic Client projection store. Do not add a dedicated reconnect fetch, event reducer, or Session summary mirror for the same fact.
+6. Keep non-Session catalogs and ephemeral UI state in their own owners, and define readiness before combining them with a projection value.
+
+These rules apply to new Session-derived Client state even when a direct event scan appears cheap. Complexity is measured across cold reads, reconnect, multiple tabs, plugin lifetime, and future consumers rather than at the first call site.
+
+### Relationship to existing decisions
+
+- [Reusable Session preparation](2026-08-05-session-preparation.md) owns cold materialization, repair, reservation, and publication. Observation adds a shared read lease over that prepared object; it does not move preparation into SessionQuery.
+- [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md) owns stream generations and replacement semantics. This decision supplies the exact snapshot that opens each journal generation.
+- [Projection state and Client views](2026-08-19-session-projection-state-and-client-views.md) owns the distinction between Host fold state and Client values. This decision governs where those values are consumed and how partial list hints differ from a complete baseline.
+- [Subagent identity projection](2026-08-06-subagent-list-identity-projection.md) continues to own descriptor folding, the serializable `null` sentinel, and the own-suffix sequence check. This decision supersedes only its independent corpus merge and direct cold-inspection path: listing now uses SessionQuery's corpus and observation.
+- The broader [session projection and command-log proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) remains proposed for the portions not represented by shipped code. This decision records the shipped observation and Client-ownership subset.
+
+## Verification
+
+Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery.
+
+Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing.
+
+## Alternatives considered
+
+**Keep source resolution in each consumer.** Rejected because every caller would continue to implement its own live race, persistence error mapping, preparation lifetime, cancellation, and projection cut, allowing both duplicate work and inconsistent results.
+
+**Activate an Agent for every exact read.** Rejected because list, history, attachment, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads.
+
+**Prepare only requested projection keys.** Rejected because a partially projected Session creates another lifecycle state that every cache, restore, plugin-registration, and caller path must track. Projection units are pure and few; computing all registered units for an exact observation is simpler than maintaining `O(E*k)` partial state instead of `O(E*P)` complete state.
+
+**Cache each projection's viewed value separately.** Rejected because `view` is a pure synchronous conversion over already folded state. A second `viewReady`/`viewedState`/`viewedValue` cache adds invalidation and plugin-lifetime states without avoiding event folding.
+
+**Keep dedicated summary fields, RPCs, or Client reducers.** Rejected because each creates a second authority beside the event log and projection registry. It also requires separate baseline, reconnect, and race handling for every domain value.
+
+**Require complete projections on every list row.** Rejected because listing a large cold corpus would force full-log work before navigation can render. Partial cache hints preserve a cheap list path; consumers already have an explicit unknown state until opening supplies the exact baseline.
+
+**Render guessed defaults while catalog or projection input is missing.** Rejected because the guess can visibly disagree with the Session and then change after loading. Initial uncertainty renders as loading; refresh retains the last complete value until the replacement is ready.
+
+## Consequences
+
+Session consumers share one live-preferred read model and one prepared cold object. Header, events, cursor, and projections belong to the same observation, and ordinary page opening can reuse that object for later promotion. New point-read consumers use SessionQuery instead of composing persistence and registry calls themselves.
+
+Session-derived Client state has one extension path: record or identify the durable input, register a pure projection unit, and consume its finished value through the generic store. Domain-specific catalogs may remain separate when they are not Session-derived, but they cannot substitute a default for an unknown Session projection.
+
+The simpler state model accepts bounded extra computation. An exact projected cold observation evaluates every registered unit, and a small cache-missing list artifact may be read in full. Large list rows can remain partially described until opened, so every list consumer must preserve the distinction among unknown, absent capability, and explicit no value. Observation leases also make disposal part of the caller contract; retaining a prepared source without releasing it prevents normal cache retirement.

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

@@ -0,0 +1,201 @@
+# Agent Note: Session observation 与 projection 所有的客户端状态
+
+Status: implemented
+
+[English](2026-08-25-session-observations-and-projection-owned-client-state.md) | 中文
+
+## 问题
+
+面向 Session 的多个消费方需要相同的逻辑数据,却各自完成解析。list、follow、page、附件/fork 读取和 subagent 检查分别在已挂载 Session、持久化元数据、prepared Session 与 projection cache 之间作选择。因此一次页面访问可能多次物化同一份冷日志,各自拼装的 header、事件、cursor 和 projection 值也可能来自不同的读取切面。
+
+客户端功能还以多种形式保存 Session 派生事实。title 有专门的 list 和更新逻辑;模型选择把 Session 专用 catalog 请求与本地状态混在一起;agent preset 展示可能在当前 Session 到达前猜测全局默认值;subagent list 则单独扫描或重建 identity。这些镜像产生了中间状态:即使持久化 Session 已经决定答案,UI 仍会短暂显示猜测出的默认值、原始 id 或不可用状态。
+
+仅统一持久化读取,Client 镜像仍会成为互相竞争的真源。仅统一 Client 字段,各 Host 入口仍可能获得不同的数据切面。因此读取单元与派生状态单元需要一条配套的 ownership 规则。
+
+## 决策
+
+Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露的可回放 Session 派生值使用已注册 projection。Observation 负责选择数据源和提供一份不可变读取切面;projection 负责从该切面派生状态。API 层只选择要发布的内容,Client 只消费成品值,不从事件重建 Session 事实,也不在各领域镜像中重复保存这些事实。
+
+### 数据动线
+
+两条 ownership 规则在 observation 的 projection snapshot 处汇合。轻量 list 可以止于 cache hints;每次精确 opening 都进入同一 observation 路径,并向 Client 提供完整 replacement baseline。
+
+```mermaid
+flowchart LR
+  List["list / search"] --> Corpus["SessionQuery corpus"]
+  Follow["follow"] --> Observe["observeSession"]
+  Page["page / attachment / fork"] --> Observe
+  Subagent["subagent list / continuation"] --> Corpus
+  Subagent --> Observe
+  Corpus --> Cache["projection cache hints"]
+  Cache --> ClientList["Client Session list"]
+  Cache -->|"small miss"| Observe
+  Observe --> Source{"live or cold"}
+  Source --> Live["attached Session cut"]
+  Source --> Borrow["borrowSession"]
+  Borrow --> Prepared["SessionPreparations.borrow"]
+  Live --> Mode{"all or none"}
+  Prepared --> Mode
+  Mode --> Snapshot["SessionObservation"]
+  Snapshot --> Opening["follow opening snapshot"]
+  Snapshot --> Read["page / inspection"]
+  Opening --> Store["Client projection store"]
+  Store --> Domain["title / model / preset / subagent"]
+```
+
+### Observation 是 point read 单元
+
+`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则 `SessionPersistence.borrowSession()` 与 `SessionPreparations.borrow()` 共享并固定一份 prepared Session,包括尚未完成的冷加载。
+
+每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。
+
+### 数据源解析与生命周期
+
+一份 observation 把所有返回字段绑定到同一 lifecycle witness。调用方不会把 corpus list 的 header、persistence 的 events 和稍后 live Session 的 projections 拼在一起。选中的 header 与事件前缀共同产生 cursor 和 projection snapshot。
+
+系统在 cold borrow 前后都检查 live 优先级。第二次检查封住 persistence 加载期间 Agent 完成 attach 的竞态。如果 persistence 报告由 live source 胜出,但 SessionQuery 检查时该 source 已经 detach,解析会重新开始,而不是发布一份无人持有的引用。
+
+只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。
+
+Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。
+
+Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。
+
+### Projection 执行边界
+
+对于 live source,`all` 读取一份同步 registry snapshot。对于 prepared source,projection cache 可以播种有效 state row,随后每个已注册 unit 在精确的剩余事件前缀上推进。得到的 Client value 共用一个 `asOfSeq`。
+
+筛选发生在计算完成之后,因为它改变的是披露内容,而不是状态。Page 鉴权可以只消费 `subagent`,list row 可以只发布 list 相关值;只要它们请求 projection 工作,仍然依赖一份完整 projected cut。
+
+Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`、schema 和 `stateVersion`。SessionQuery 只知道是否需要 projection 工作,不理解 title、model、preset、subagent、token、image、plan、todo 或 goal 的语义。
+
+`view` 保持为 folded state 上无 cache 的同步转换。其成本由已注册 projection unit 数量界定,并在 snapshot 发布时支付;引入第二层 cache 只会增加 invalidation 状态,无法减少 event replay。
+
+语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 与 subagent list 先读取 live projection 状态或持久 projection-cache row。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。
+
+`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。
+
+### 读取 audience
+
+每个公开操作选择一组查询与 projection 策略。该选择属于操作本身的行为,而不是 persistence 或 transport 内部的启发式判断。
+
+| 操作 | 读取路径 | Projection 策略 | Agent 激活 |
+|---|---|---|---|
+| `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 |
+| `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 |
+| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 |
+| `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 |
+| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 |
+| Subagent list 与 continuation | Corpus 加 live/cache/observation 解析 | cold fallback 全算;audience 只消费 identity 或继承值 | Listing 从不;continuation 遵循显式命令语义 |
+
+### 可回放的 Client 事实归 projection 所有
+
+当一个 Client 可见值由 Session header 或事件日志决定,并且必须在刷新、冷访问或重连后恢复时,它属于 `SessionProjectionMap`。这条规则覆盖 title、list metadata、model selection、agent preset selection、subagent identity 和 subagent timing。各领域包拥有纯 projection definition;Session transport 与 Client value store 不理解具体领域。
+
+Projection 的三种交付状态含义不同:
+
+- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。
+- Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。
+- 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。
+
+这些区别避免由一个重载的 `undefined` 同时表示 cache miss、plugin 未加载和真实领域答案。API 类型把 list 数据命名为 hints,把 opening 数据命名为 baseline,因此消费方不能只因两者都携带 projection value 就假定其完整性相同。
+
+### Client 合并规则
+
+| 输入 | 完整性 | 新鲜度 | key 缺失的含义 |
+|---|---|---|---|
+| Session list hints | 部分 | 上次持久 checkpoint 或有界 fallback cut | 未知 |
+| Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
+| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
+
+Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。
+
+List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。
+
+每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。
+
+不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。
+
+Client 本地交互状态也继续留在本地:loading 和 error 状态、打开的菜单、进行中的选择,以及为尚未创建 Session 暂存的选择都不是可回放 Session 事实。选择一旦应用到 Session,其持久事件与 projection 就成为权威。
+
+### 领域应用
+
+- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。
+- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。
+- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。
+- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。
+- **Subagent presentation。** Opening projection value 在 Client 宣布 child 可交互或离线前建立 timing 与 identity,因此 transport loading 不会伪装成 durable state。
+
+这些迁移删除特殊 Client state,但不会让 projection 接管 provider catalog 或交互机制。领域仍拥有 mutation 和 command;projection 只拥有其可回放 Session 结果。
+
+### 失败与 readiness 边界
+
+- List cache miss 不是错误,也不会隐藏 row。未知 hints 保持缺失,直到有界 fallback 或精确 opening 提供值。
+- 精确 cold observation 中的 projection failure 使整份 observation 按损坏的 Session data 失败;调用方不会发布成功 key 与失败 key 的混合结果。
+- 一个 subagent candidate 的 cold observation 失败只影响该 candidate 的 diagnostic row;sibling candidate 继续可用。
+- Catalog load failure 是 Client 可见的 catalog state。它不会在 refresh 期间清除上一份完整 catalog,也不会合成 Session selection。
+- Follow carrier generation 只有在 opening snapshot 完成校验与应用后才被接受。Reconnect 期间继续显示上一 generation。
+
+取消会在文档规定的检查点终止排队中或进行中的 cold resolution,并释放每一份已获得 lease。取消不会变成 not-found,也不能让 prepared entry 保持 pinned。
+
+### Ownership 矩阵
+
+| 事项 | Owner | 非 owner |
+|---|---|---|
+| Cold materialization 与 revision 检查 | Session persistence | API Controller 与 Client |
+| 精确 live-preferred read cut | SessionQuery observation | 各 endpoint helper |
+| Fold state 与 Client-value 计算 | Projection registry 与 domain unit | SessionQuery 与 Client |
+| 部分 list acceleration | Projection cache 与 list policy | Follow protocol |
+| Opening 与 reconnect replacement | Session follow 与 journal stream | Session page |
+| Per-key value ordering | Client projection store | Domain UI component |
+| Provider 或 preset catalog lifecycle | 对应 catalog directory | Session projection |
+| Rendering 与瞬时 interaction state | Domain UI package | Host projection unit |
+
+### 扩展规则
+
+1. 判断新值是否属于单个 Session 的可回放事实;如果属于,先定义或复用其持久 header/event 输入,再添加 Client 字段。
+2. 在 owning domain 注册一个 pure projection unit。Fold state 与 Client view 表示不同时,分别定义其类型。
+3. 让精确读方请求 `projectionMode: 'all'`;仅在构造 audience-specific response 时筛选。
+4. 让 list 消费方接受 optional hint。不能只为消除显式 unknown state 而强制 hydrate 整个 corpus。
+5. 把值送入通用 Client projection store。不能为同一事实再增加 dedicated reconnect fetch、event reducer 或 Session summary mirror。
+6. 非 Session catalog 与 ephemeral UI state 保留各自 owner,并在和 projection value 组合前定义 readiness。
+
+这些规则适用于新的 Session-derived Client state,即使在第一个调用点直接扫描 event 看似廉价。复杂度需要覆盖 cold read、reconnect、多 tab、plugin lifetime 和未来消费方,而不是只看首次实现。
+
+### 与既有决策的关系
+
+- [可复用 Session preparation](2026-08-05-session-preparation.zh.md)拥有冷物化、修复、reservation 和发布。Observation 在该 prepared object 之上增加共享读取 lease,并未把 preparation 移入 SessionQuery。
+- [Session 历史与 Remote event transport](2026-08-18-session-history-and-event-transport.zh.md)拥有 stream generation 与 replacement 语义。本决策提供每个日志 generation 的精确 opening snapshot。
+- [Projection state 与 Client view](2026-08-19-session-projection-state-and-client-views.zh.md)拥有 Host fold state 和 Client value 的区分。本决策规定这些值在哪里消费,以及部分 list hints 与完整 baseline 的差别。
+- [Subagent identity projection](2026-08-06-subagent-list-identity-projection.zh.md)继续拥有 descriptor folding、可序列化 `null` sentinel 和 own-suffix sequence 检查。本决策只取代其中独立 corpus merge 和直接 cold inspection 路径:listing 改为使用 SessionQuery corpus 和 observation。
+- 更广泛的 [session projection 与 command-log 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)仍为 proposed,其中尚未由已交付代码体现的部分不受影响。本决策记录已经交付的 observation 与 Client ownership 子集。
+
+## 验证
+
+Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。
+
+Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。
+
+## 考虑过的替代方案
+
+**由各消费方继续解析数据源。** 否决,因为每个调用方都需要重复实现 live race、persistence error mapping、preparation lifetime、cancellation 和 projection cut,既会重复工作,也会产生不一致结果。
+
+**每次精确读取都激活 Agent。** 否决,因为 list、history、attachment、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。
+
+**只 prepare 被请求的 projection key。** 否决,因为只完成部分 projection 的 Session 会增加一种生命周期状态,所有 cache、restore、plugin registration 和调用路径都必须追踪它。Projection unit 数量少且为纯函数;精确 observation 计算全部已注册 unit,比为了 `O(E*k)` 而维护部分状态、取代 `O(E*P)` 完整状态更简单。
+
+**单独缓存每个 projection 的 viewed value。** 否决,因为 `view` 只是已折叠 state 上的纯同步转换。第二层 `viewReady`/`viewedState`/`viewedValue` cache 会增加 invalidation 和 plugin lifetime 状态,却不能减少 event folding。
+
+**保留专用 summary 字段、RPC 或 Client reducer。** 否决,因为每一项都会在 event log 与 projection registry 之外建立第二个真源,还要求每个领域分别实现 baseline、reconnect 和 race handling。
+
+**要求每个 list row 都携带完整 projection。** 否决,因为列出大型 cold corpus 时必须先读取完整日志,navigation 才能渲染。部分 cache hints 保留了轻量 list 路径;在 opening 给出精确 baseline 前,消费方已经拥有明确的 unknown 状态。
+
+**在 catalog 或 projection 输入缺失时渲染猜测默认值。** 否决,因为猜测可能明显违背 Session,并在加载后发生跳变。初次不确定时显示 loading;刷新时保留上一份完整值,直到替代值就绪。
+
+## 后果
+
+Session 消费方共享一份 live-preferred read model 和一个 prepared cold object。Header、events、cursor 与 projections 属于同一 observation,普通页面打开还可以为后续 promotion 复用该对象。新的 point-read 消费方使用 SessionQuery,而不再自行拼接 persistence 与 registry 调用。
+
+Session 派生 Client 状态只有一条扩展路径:记录或识别持久输入、注册纯 projection unit,再通过通用 store 消费其成品值。不由 Session 派生的领域 catalog 可以独立存在,但不能用默认值替代未知的 Session projection。
+
+更简单的状态模型接受有界的额外计算。精确的 projected cold observation 会计算所有已注册 unit,小型且 cache miss 的 list artifact 可能完整读取。大型 list row 在打开前可以保持部分描述,因此每个 list 消费方必须保留 unknown、capability absent 和 explicit no value 的区别。Observation lease 还使 dispose 成为调用方约定的一部分;保留 prepared source 而不释放会阻止正常 cache retirement。

+ 1 - 0
apps/cli/package.json

@@ -124,6 +124,7 @@
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
     "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-settings-file": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",

+ 10 - 15
apps/cli/tests/github-webhook-real.e2e.ts

@@ -28,8 +28,8 @@ interface SessionList {
   items: Array<{
     sessionId: string
     cwd?: string
-    agentPreset?: string
     blank: boolean
+    projections?: { values: { agentPreset?: string | null } }
   }>
 }
 
@@ -222,23 +222,18 @@ async function workspaceBaseline(baseUrl: string): Promise<WorkspaceBaseline> {
   return frame.value as WorkspaceBaseline
 }
 
-/** Read the explicit page cut from a fresh Session follow generation. */
-async function sessionCursor(baseUrl: string, sessionId: string): Promise<number> {
+/** Read the complete opening page from a fresh Session follow generation. */
+async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
   const frame = await openingStreamItem(
     baseUrl,
     'session/follow',
-    { request: { address: { kind: 'session', sessionId } } },
-    value => isRecord(value) && value.type === 'opened' && Number.isSafeInteger(value.cursor),
+    { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } },
+    value => isRecord(value)
+      && value.type === 'snapshot'
+      && Array.isArray(value.events)
+      && typeof value.hasMore === 'boolean',
   )
-  return frame.cursor as number
-}
-
-/** Read Session history at the cursor explicitly opened for this page. */
-async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
-  const throughSeq = await sessionCursor(baseUrl, sessionId)
-  return remoteRpc<HistoryPage>(baseUrl, 'session/page', {
-    request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 100 },
-  })
+  return { events: frame.events as HistoryPage['events'], hasMore: frame.hasMore as boolean }
 }
 
 /** Poll a public observation until it satisfies the test's behavior predicate. */
@@ -379,9 +374,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real
 
       const sessions = await remoteRpc<SessionList>(baseUrl, 'session/list', { _request: {} })
       expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({
-        agentPreset: 'minimal',
         blank: false,
         cwd: canonicalWorkspacePath,
+        projections: { values: { agentPreset: 'minimal' } },
       })
 
       const admitted = await eventually(

+ 3 - 0
apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml

@@ -10,6 +10,9 @@
     root: './.sessions'
     compression: none
 
+- id: session-query
+  name: './tests/fixtures/subagent-diagnostic-query.ts'
+
 # file/override both default to their DSH_SNAPSHOT_* env vars.
 - id: replay
   name: '@deepseek-ai/dsh-llm-replay'

+ 14 - 0
apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts

@@ -0,0 +1,14 @@
+/** Exact-read Session query used by the descriptor-less child snapshot. */
+
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+
+/** Search is outside this fixture; inherited corpus and observation reads stay real. */
+export default class SubagentDiagnosticQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is unavailable in this fixture'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is unavailable in this fixture'))
+  }
+}

+ 3 - 18
apps/cli/tests/web-agent-presets.e2e.ts

@@ -12,7 +12,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
-import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
+import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
 import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-compaction-basic'
@@ -629,27 +629,12 @@ describe('a switch survives the session', () => {
 
       // The header keeps the creation fact; the log carries what it runs.
       expect(handle.agent.session.header.agentPreset).toBe('standard')
-      expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
+      expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal')
     } finally {
       await handle.dispose()
     }
   })
 
-  it('rebuilds a switched session from the log, not the creation header', () => {
-    // The exact shape a resume reads back from disk: the header says standard,
-    // the log records the switch the user made while the session was blank.
-    const rebuilt = resolveSessionPreset({
-      header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
-      events: [
-        { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
-        { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
-      ] as never,
-    })
-
-    // Reading the header alone would compose the creation-time preset over a
-    // history another one produced — the replay the blank-only lock prevents.
-    expect(rebuilt).toBe('minimal')
-  })
 })
 
 describe('a forked session', () => {
@@ -659,7 +644,7 @@ describe('a forked session', () => {
       meta: { agentPreset: 'minimal' },
       setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
     })
-    const inherited = resolveSessionPreset(parent.agent.session)
+    const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined
     const child = await ctx.agents.create({
       sessionId: SessionId('preset-fork-child'),
       meta: {

+ 3 - 0
apps/cli/tsconfig.json

@@ -65,6 +65,9 @@
     {
       "path": "../../packages/session-query/session-query-sqlite"
     },
+    {
+      "path": "../../packages/session-query/session-query"
+    },
     {
       "path": "../../packages/shell/shell-env"
     },

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

@@ -150,9 +150,18 @@ async function livePreset(baseUrl: string): Promise<string | undefined> {
     }),
   })
   const body = await response.json() as {
-    result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
+    result: {
+      value?: {
+        items: {
+          sessionId: string
+          projections?: { values: { agentPreset?: string | null } }
+        }[]
+      }
+    }
   }
-  return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
+  const preset = body.result.value?.items.find(item => item.sessionId !== SEED_ID)
+    ?.projections?.values.agentPreset
+  return typeof preset === 'string' ? preset : undefined
 }
 
 /** Every option label the trigger menu currently lists. */

+ 12 - 0
apps/web/tests/cordis-tool-round.e2e.ts

@@ -69,6 +69,8 @@ describe('web e2e: Cordis tools use their owned cards', () => {
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   const sessionEvents: SessionEvent[] = []
+  const modelFrames: string[] = []
+  const modelChanges: string[] = []
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({
@@ -77,8 +79,17 @@ describe('web e2e: Cordis tools use their owned cards', () => {
       ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
     })
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    scaffold.ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+      if (key === 'modelSelection') modelChanges.push(`${String(seq)}:${JSON.stringify(value)}`)
+    })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    page.on('websocket', (socket) => {
+      socket.on('framereceived', (frame) => {
+        const payload = String(frame.payload)
+        if (payload.includes('modelSelection')) modelFrames.push(payload)
+      })
+    })
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -169,6 +180,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
 
   it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria'))
+    console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames })
     await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight })
     await expect.poll(
       async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(),

+ 8 - 3
apps/web/tests/default-model.e2e.ts

@@ -46,9 +46,14 @@ describe('web e2e: the composer model switch is the default for later sessions',
     return response.sessionId
   }
 
-  /** The route the gateway reports for one session, through the real wire face. */
-  const currentOf = async (sessionId: string): Promise<unknown> => {
-    return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current
+  /** The route the Client derives from the Session projection and Host default. */
+  const currentOf = (sessionId: string): Promise<unknown> => {
+    const session = scaffold.ctx.sessions.get(SessionId(sessionId))
+    if (session === undefined) throw new Error(`session "${sessionId}" is not live`)
+    return Promise.resolve(
+      scaffold.ctx.sessionProjections.snapshot(session).values.modelSelection?.next
+        ?? scaffold.ctx.agentDefaultModel.currentSelection(),
+    )
   }
 
   beforeAll(async () => {

+ 2 - 2
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -42,8 +42,8 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
-- button "Select model":
-  - text: Select model
+- button "Select model, current github-webhook-review-test/reply":
+  - text: github-webhook-review-test/reply
   - img
 - button "Send message" [disabled]
 - text: 1 turns · 1 steps LLM {{duration}}

+ 1 - 1
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -234,7 +234,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     // trigger — on the page; the scaffold boots without one.
     await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
 
-    const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
+    const modelTrigger = page.getByRole('button', { name: /^选择模型/ })
     await modelTrigger.waitFor({ timeout: 10_000 })
     await modelTrigger.click()
     await page.getByRole('menuitem', { name: /模型/ }).click()

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

@@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     const group = page.getByRole('treeitem', { name: /Ungrouped/ })
     await group.waitFor({ timeout: 15_000 })
     if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
-    const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first()
+    const target = page.getByRole('treeitem', { name: /Reference order target/ })
     await target.waitFor({ timeout: 15_000 })
     await target.click()
     await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })

+ 16 - 25
apps/web/tests/seeded-history.e2e.ts

@@ -228,44 +228,35 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await recordFixture(scaffold, sessionId, SEED)
   }, 200_000)
 
-  it.skipIf(MODE === 'record')('serves the projections baseline on the real composition tail page', async () => {
+  it.skipIf(MODE === 'record')('serves the projections baseline on the real composition opening snapshot', async () => {
     // Composition regression tripwire: the projection registry must be a row
     // in the SHIPPED cordis.yml — with it absent every domain unit's optional
     // injection stays silent and this block disappears (no titles/todos on
     // the web), while fixture-level suites stay green. Assert through the
-    // real HTTP wire against the booted real host.
-    const response = await fetch(`${scaffold.baseUrl}/api/session/page`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json' },
-      body: JSON.stringify({
-        type: 'client-request', rpcId: 'seeded-projections', method: 'session/page',
-        payload: {
-          args: { request: {
-            address: { kind: 'session', sessionId: SEED_ID },
-            throughSeq: seededThroughSeq,
-          } },
-        },
-      }),
-    })
-    expect(response.ok).toBe(true)
-    const body = await response.json() as {
-      result: { ok: boolean; value?: { projections?: { asOfSeq: number; values: Record<string, unknown> } } }
+    // production Session Controller against the booted real host.
+    const controller = new AbortController()
+    const stream = scaffold.ctx.sessionController.follow({
+      address: { kind: 'session', sessionId: SessionId(SEED_ID) },
+    }, controller.signal)[Symbol.asyncIterator]()
+    const first = await stream.next()
+    controller.abort()
+    if (first.done || first.value.type !== 'snapshot') {
+      throw new Error('session follow did not publish its opening snapshot')
     }
-    expect(body.result.ok).toBe(true)
-    const projections = body.result.value?.projections
-    expect(projections).toBeDefined()
-    expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
+    expect(first.value.cursor).toBe(seededThroughSeq)
+    const projections = first.value.projections
+    expect(projections.asOfSeq).toBe(seededThroughSeq)
     // The seed carries a session/title event: the title unit is host-plane, so
     // it folds the detached log and serves the value with nothing composed.
-    expect(typeof projections?.values.title).toBe('string')
+    expect(typeof projections.values.title).toBe('string')
     // `todos` is absent because its unit belongs to the agent preset and this
     // directly seeded session never composed that preset. History computes
     // the baseline through the standard projection registry without mounting
     // an Agent composition as a read side effect.
-    expect(projections?.values).not.toHaveProperty('todos')
+    expect(projections.values).not.toHaveProperty('todos')
     // The session-stats unit is a shipped web-app bundle row: whole-log
     // turn/step counts ride the same tail block (the stats strip's source).
-    const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
+    const sessionStats = projections.values.sessionStats as { turns: number; steps: number } | undefined
     expect(sessionStats).toBeDefined()
     expect(sessionStats?.turns).toBeGreaterThanOrEqual(1)
     expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0)

+ 1 - 1
apps/web/tests/smoke-real.e2e.ts

@@ -125,7 +125,7 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise<number
           }
           const value = frame.value
           if (frame.type === 'item' && isRecord(value)
-            && value.type === 'opened' && Number.isSafeInteger(value.cursor)) {
+            && value.type === 'snapshot' && Number.isSafeInteger(value.cursor)) {
             finish(undefined, value.cursor as number)
           }
         } catch (error) {

+ 39 - 67
apps/web/tests/startup-auto-selection.e2e.ts

@@ -1,46 +1,19 @@
-// Web e2e scenario: startup auto-selection keeps the hero on screen.
-//
-// A page load with a workspace already registered runs
-// `WorkspaceRuntime.startInitialSelection`: it connects the most recent
-// workspace and opens its blank session. `openState` flips to `loading` the
-// moment `open()` lands; driving `data-phase=settling` on the conversation
-// root from that flip would hide the composer seat and the header
-// (`visibility:hidden`) for the whole `session.page` round-trip — the
-// center column blanks and repaints like a full-page refresh on every launch.
-//
-// The unit spec pins the phase condition over hand-built stores. What only the
-// assembled application can show is that the path a user actually takes
-// reaches it: the real selection service, the real client session opening over
-// the real /api transport, and a real browser deciding what is painted.
-// The initial Workspace pick also records the resident Hero/composer nodes and
-// proves that opening the first blank Session fills the strict outlets without
-// replacing those nodes.
-//
-// The round-trip against a loopback host is far too fast to observe, so this
-// scenario HOLDS the `session.page` response open in the browser's network
-// handler and asserts the visible frame while it is in flight. That wait is
-// what makes the assertions non-vacuous: without the phase exemption, the held
-// window is exactly when `settling` would be painted and the composer hidden.
-//
-// Zero model calls: registering a workspace and opening its blank session are
-// host RPCs with no model involvement. A stray stream would fail loud with
-// NO_ADAPTER.
+/** Web acceptance that startup Session opening preserves the resident Hero tree. */
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import {
+  acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold,
+} from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
-/** Wire path of the history round-trip the conversation root waits out. */
-const HISTORY_ROUTE = '**/api/session/page'
-
 /**
  * The conversation root's own phase attribute. `div` disambiguates it from the
  * composer textarea, which carries an unrelated `data-phase` of its own.
  */
 const ROOT_PHASE = 'div[data-phase]'
 
-/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
+/** Every distinct conversation-root phase observed during one page load. */
 function recordedPhases(page: Page): Promise<string[]> {
   return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
 }
@@ -114,10 +87,8 @@ describe('web e2e: startup auto-selection', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 120_000)
 
-  it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
+  it('keeps the hero and composer visible while the opening follow snapshot is pending', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
-    // Runs before any page script on the reload below, so the first phase the
-    // root ever renders is recorded, not just the ones after a listener attaches.
     await page.addInitScript(() => {
       const phases: string[] = []
       ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
@@ -128,41 +99,42 @@ describe('web e2e: startup auto-selection', () => {
       }, 8)
     })
 
-    let releaseHistory = (): void => {}
-    const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
-    let historyRequested = (): void => {}
-    const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
+    let releaseOpening = (): void => {}
+    const openingHeld = new Promise<void>((resolve) => { releaseOpening = resolve })
+    let openingRequested = (): void => {}
+    const openingInFlight = new Promise<void>((resolve) => { openingRequested = resolve })
     let gated = false
-    await page.route(HISTORY_ROUTE, async (route) => {
-      // Only the auto-selection's own round-trip is held; later pages must not
-      // deadlock behind a gate this test has already released.
-      if (gated) { await route.continue(); return }
-      gated = true
-      historyRequested()
-      await historyHeld
-      await route.continue()
-    })
+    const readObservation = scaffold.ctx.sessionQuery.observeSession
+      .bind(scaffold.ctx.sessionQuery)
+    const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
+      .mockImplementation(async (sessionId, options) => {
+        const observation = await readObservation(sessionId, options)
+        if (gated) return observation
+        gated = true
+        openingRequested()
+        await openingHeld
+        return observation
+      })
 
     const warningsBefore = tripwire.warnings.length
-    await page.reload({ waitUntil: 'commit' })
-    await historyInFlight
+    try {
+      await page.reload({ waitUntil: 'commit' })
+      await openingInFlight
 
-    // The frame a user sees while the session is still opening: hero phase, the
-    // hero title, and a composer that is actually painted (`settling` hides the
-    // seat with `visibility:hidden`, which Playwright reports as not visible).
-    await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
-    expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
-    expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
-    expect(await page.locator('textarea').first().isVisible()).toBe(true)
+      await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
+      expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
+      expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
+      expect(await page.locator('textarea').first().isVisible()).toBe(true)
 
-    releaseHistory()
-    await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
-      .waitFor({ timeout: 15_000 })
-    acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
-
-    // Settling is not merely absent from the frame sampled above: the root
-    // never entered it at any point of the load.
-    expect(await recordedPhases(page)).toEqual(['hero'])
-    expect(tripwire.pageErrors).toEqual([])
+      releaseOpening()
+      await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
+        .waitFor({ timeout: 15_000 })
+      acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+      expect(await recordedPhases(page)).toEqual(['hero'])
+      expect(tripwire.pageErrors).toEqual([])
+    } finally {
+      releaseOpening()
+      observe.mockRestore()
+    }
   }, 120_000)
 })

+ 32 - 0
apps/web/tests/subagent-conversation.e2e.ts

@@ -353,6 +353,38 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
   })
 
+  it('keeps a restored child neutral until its parent availability arrives', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
+    const pattern = '**/api/subagent.list'
+    let requested = false
+    let releaseCatalog = (): void => {}
+    const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
+    await page.route(pattern, async (route) => {
+      const response = await route.fetch()
+      requested = true
+      await catalogHeld
+      await route.fulfill({ response })
+    })
+
+    const warningStart = tripwire.warnings.length
+    try {
+      await page.reload({ waitUntil: 'load' })
+      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await expect.poll(() => requested, { timeout: 15_000 }).toBe(true)
+      expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0)
+      expect(await page.locator('[data-composer-seat]').evaluate(element =>
+        getComputedStyle(element).visibility)).toBe('hidden')
+      releaseCatalog()
+      const input = page.getByRole('textbox', { name: 'Message the agent' })
+      await input.waitFor({ timeout: 15_000 })
+      await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
+      acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    } finally {
+      releaseCatalog()
+      await page.unroute(pattern)
+    }
+  })
+
   it('continues through FIFO follow-up admission and receives the child follow events', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
     const ended = new Promise<void>((resolveEnded, reject) => {

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 603f96385b1ab9a804ce2fa17f012343887c929a
-config-catalog.zh.md: 97ebe046478eb195a99e5c820684cf24f111da20
+config-catalog.md: 5991c911d33b34db51c12313036322acf94dcaf9
+config-catalog.zh.md: 80b8163fc46bccc9509f51730bfcb7e256834f15

+ 5 - 5
docs/config-catalog.md

@@ -280,12 +280,12 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
+Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
 export interface Config {
-  /** Maximum cold Session artifact size read to determine blankness. */
+  /** Maximum cold Session artifact size eligible for one full projection observation. */
   readonly coldBlankProbeMaxBytes?: number
 }
 ```
@@ -1781,7 +1781,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-sqlite"></a>
 
@@ -1808,7 +1808,7 @@ export interface Config {
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-Source: [`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts)
+Source: [`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 
@@ -1831,7 +1831,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 

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

@@ -282,12 +282,12 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
+需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
 export interface Config {
-  /** Maximum cold Session artifact size read to determine blankness. */
+  /** Maximum cold Session artifact size eligible for one full projection observation. */
   readonly coldBlankProbeMaxBytes?: number
 }
 ```
@@ -1783,7 +1783,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-sqlite"></a>
 
@@ -1810,7 +1810,7 @@ export interface Config {
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-来源:[`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts)
+来源:[`packages/session/session-persistence-sqlite/src/index.ts:38`](../packages/session/session-persistence-sqlite/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 
@@ -1833,7 +1833,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts)
+来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1
-event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72
+event-producer-consumer.md: 586316e90992447d45ce2b0f0d67c306689f95cb
+event-producer-consumer.zh.md: 4aebaa2f10e4975df238639a1dac40694f50bed2

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

@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
-| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
+| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

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

@@ -10,7 +10,7 @@
 | 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
-| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
+| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
@@ -23,11 +23,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -47,7 +47,7 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: ea402f19468fe92455f828a23f3478235903776b
-module-graph.zh.md: af3efe50e535791d4f060a5b598ddf52013b465d
+module-graph.md: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b
+module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe

+ 129 - 124
docs/module-graph.md

@@ -889,6 +889,7 @@ flowchart TD
   pkg_agent_presets --> pkg_invariants
   pkg_agent_presets --> pkg_scope
   pkg_agent_presets --> pkg_session
+  pkg_agent_presets --> pkg_session_projection
   pkg_agent_presets --> pkg_settings
   pkg_agent_presets --> pkg_system_prompt
   pkg_agent_presets --> pkg_tools
@@ -977,26 +978,13 @@ flowchart TD
   pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions
   pkg_plugin_package_inventory_deepseek --> pkg_invariants
   pkg_plugin_package_inventory_deepseek --> pkg_session
-  pkg_subagent --> pkg_agent
-  pkg_subagent --> pkg_agent_presets
-  pkg_subagent --> pkg_brand
-  pkg_subagent --> pkg_invariants
-  pkg_subagent --> pkg_jobs
-  pkg_subagent --> pkg_llm
-  pkg_subagent --> pkg_sandbox
-  pkg_subagent --> pkg_sandbox_policy
-  pkg_subagent --> pkg_scope
-  pkg_subagent --> pkg_session
-  pkg_subagent --> pkg_session_persistence
-  pkg_subagent --> pkg_session_projection
-  pkg_subagent --> pkg_session_projection_cache
-  pkg_subagent --> pkg_tools
-  pkg_subagent --> pkg_user_approval
   pkg_session_query --> pkg_brand
   pkg_session_query --> pkg_invariants
   pkg_session_query --> pkg_llm
   pkg_session_query --> pkg_session
   pkg_session_query --> pkg_session_persistence
+  pkg_session_query --> pkg_session_projection
+  pkg_session_query --> pkg_session_projection_cache
   pkg_session_query --> pkg_session_title
   pkg_session_query --> pkg_tool_todo
   pkg_acp --> pkg_agent
@@ -1058,60 +1046,22 @@ flowchart TD
   pkg_webhook --> pkg_session
   pkg_webhook --> pkg_session_title
   pkg_webhook --> pkg_workspace
-  pkg_subagent_acp --> pkg_agent
-  pkg_subagent_acp --> pkg_invariants
-  pkg_subagent_acp --> pkg_llm
-  pkg_subagent_acp --> pkg_session
-  pkg_subagent_acp --> pkg_subagent
-  pkg_subagent_acp --> pkg_subprocess
-  pkg_subagent_acp --> pkg_timeout
-  pkg_subagent_claude_code --> pkg_invariants
-  pkg_subagent_claude_code --> pkg_llm
-  pkg_subagent_claude_code --> pkg_session
-  pkg_subagent_claude_code --> pkg_subagent
-  pkg_subagent_claude_code --> pkg_subprocess
-  pkg_subagent_claude_code --> pkg_timeout
-  pkg_subagent_codex --> pkg_invariants
-  pkg_subagent_codex --> pkg_llm
-  pkg_subagent_codex --> pkg_session
-  pkg_subagent_codex --> pkg_subagent
-  pkg_subagent_codex --> pkg_subprocess
-  pkg_subagent_codex --> pkg_timeout
-  pkg_subagent_in_process_driver --> pkg_agent
-  pkg_subagent_in_process_driver --> pkg_invariants
-  pkg_subagent_in_process_driver --> pkg_llm
-  pkg_subagent_in_process_driver --> pkg_session
-  pkg_subagent_in_process_driver --> pkg_subagent
-  pkg_subagent_in_process_driver --> pkg_system_prompt
-  pkg_subagent_in_process_driver --> pkg_tools
-  pkg_tool_subagent --> pkg_agent
-  pkg_tool_subagent --> pkg_invariants
-  pkg_tool_subagent --> pkg_jobs
-  pkg_tool_subagent --> pkg_llm
-  pkg_tool_subagent --> pkg_scope
-  pkg_tool_subagent --> pkg_session
-  pkg_tool_subagent --> pkg_settings
-  pkg_tool_subagent --> pkg_subagent
-  pkg_tool_subagent --> pkg_system_prompt
-  pkg_tool_subagent --> pkg_tools
-  pkg_tool_subagent_control --> pkg_invariants
-  pkg_tool_subagent_control --> pkg_llm
-  pkg_tool_subagent_control --> pkg_session
-  pkg_tool_subagent_control --> pkg_subagent
-  pkg_tool_subagent_control --> pkg_tools
-  pkg_tool_subagent_report --> pkg_invariants
-  pkg_tool_subagent_report --> pkg_llm
-  pkg_tool_subagent_report --> pkg_subagent
-  pkg_tool_subagent_report --> pkg_system_prompt
-  pkg_tool_subagent_report --> pkg_tools
-  pkg_hooks_claude_code --> pkg_agent
-  pkg_hooks_claude_code --> pkg_hook_protocol
-  pkg_hooks_claude_code --> pkg_invariants
-  pkg_hooks_claude_code --> pkg_llm
-  pkg_hooks_claude_code --> pkg_session
-  pkg_hooks_claude_code --> pkg_session_persistence
-  pkg_hooks_claude_code --> pkg_subagent
-  pkg_hooks_claude_code --> pkg_tools
+  pkg_subagent --> pkg_agent
+  pkg_subagent --> pkg_agent_presets
+  pkg_subagent --> pkg_brand
+  pkg_subagent --> pkg_invariants
+  pkg_subagent --> pkg_jobs
+  pkg_subagent --> pkg_llm
+  pkg_subagent --> pkg_sandbox
+  pkg_subagent --> pkg_sandbox_policy
+  pkg_subagent --> pkg_scope
+  pkg_subagent --> pkg_session
+  pkg_subagent --> pkg_session_persistence
+  pkg_subagent --> pkg_session_projection
+  pkg_subagent --> pkg_session_projection_cache
+  pkg_subagent --> pkg_session_query
+  pkg_subagent --> pkg_tools
+  pkg_subagent --> pkg_user_approval
   pkg_session_query_sqlite --> pkg_invariants
   pkg_session_query_sqlite --> pkg_session
   pkg_session_query_sqlite --> pkg_session_persistence
@@ -1169,6 +1119,74 @@ flowchart TD
   pkg_agent_spine_demo --> pkg_tool_jobs
   pkg_agent_spine_demo --> pkg_tool_skill
   pkg_agent_spine_demo --> pkg_tools
+  pkg_experimental_webworker_runtime --> pkg_client_modules
+  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
+  pkg_experimental_webworker_runtime --> pkg_host_webserver
+  pkg_experimental_webworker_runtime --> pkg_invariants
+  pkg_webhook_github --> pkg_credentials
+  pkg_webhook_github --> pkg_host_webserver
+  pkg_webhook_github --> pkg_invariants
+  pkg_webhook_github --> pkg_session
+  pkg_webhook_github --> pkg_webhook
+  pkg_subagent_acp --> pkg_agent
+  pkg_subagent_acp --> pkg_invariants
+  pkg_subagent_acp --> pkg_llm
+  pkg_subagent_acp --> pkg_session
+  pkg_subagent_acp --> pkg_subagent
+  pkg_subagent_acp --> pkg_subprocess
+  pkg_subagent_acp --> pkg_timeout
+  pkg_subagent_claude_code --> pkg_invariants
+  pkg_subagent_claude_code --> pkg_llm
+  pkg_subagent_claude_code --> pkg_session
+  pkg_subagent_claude_code --> pkg_subagent
+  pkg_subagent_claude_code --> pkg_subprocess
+  pkg_subagent_claude_code --> pkg_timeout
+  pkg_subagent_codex --> pkg_invariants
+  pkg_subagent_codex --> pkg_llm
+  pkg_subagent_codex --> pkg_session
+  pkg_subagent_codex --> pkg_subagent
+  pkg_subagent_codex --> pkg_subprocess
+  pkg_subagent_codex --> pkg_timeout
+  pkg_subagent_in_process_driver --> pkg_agent
+  pkg_subagent_in_process_driver --> pkg_invariants
+  pkg_subagent_in_process_driver --> pkg_llm
+  pkg_subagent_in_process_driver --> pkg_session
+  pkg_subagent_in_process_driver --> pkg_subagent
+  pkg_subagent_in_process_driver --> pkg_system_prompt
+  pkg_subagent_in_process_driver --> pkg_tools
+  pkg_tool_subagent --> pkg_agent
+  pkg_tool_subagent --> pkg_invariants
+  pkg_tool_subagent --> pkg_jobs
+  pkg_tool_subagent --> pkg_llm
+  pkg_tool_subagent --> pkg_scope
+  pkg_tool_subagent --> pkg_session
+  pkg_tool_subagent --> pkg_settings
+  pkg_tool_subagent --> pkg_subagent
+  pkg_tool_subagent --> pkg_system_prompt
+  pkg_tool_subagent --> pkg_tools
+  pkg_tool_subagent_control --> pkg_invariants
+  pkg_tool_subagent_control --> pkg_llm
+  pkg_tool_subagent_control --> pkg_session
+  pkg_tool_subagent_control --> pkg_subagent
+  pkg_tool_subagent_control --> pkg_tools
+  pkg_tool_subagent_report --> pkg_invariants
+  pkg_tool_subagent_report --> pkg_llm
+  pkg_tool_subagent_report --> pkg_subagent
+  pkg_tool_subagent_report --> pkg_system_prompt
+  pkg_tool_subagent_report --> pkg_tools
+  pkg_hooks_claude_code --> pkg_agent
+  pkg_hooks_claude_code --> pkg_hook_protocol
+  pkg_hooks_claude_code --> pkg_invariants
+  pkg_hooks_claude_code --> pkg_llm
+  pkg_hooks_claude_code --> pkg_session
+  pkg_hooks_claude_code --> pkg_session_persistence
+  pkg_hooks_claude_code --> pkg_subagent
+  pkg_hooks_claude_code --> pkg_tools
+  pkg_api_gateway --> pkg_brand
+  pkg_api_gateway --> pkg_client_connection
+  pkg_api_gateway --> pkg_host_webserver
+  pkg_api_gateway --> pkg_invariants
+  pkg_api_gateway --> pkg_typert_registry
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_brand
   pkg_experimental_agent_team --> pkg_invariants
@@ -1176,19 +1194,10 @@ flowchart TD
   pkg_experimental_agent_team --> pkg_session
   pkg_experimental_agent_team --> pkg_session_persistence
   pkg_experimental_agent_team --> pkg_subagent
-  pkg_experimental_webworker_runtime --> pkg_client_modules
-  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
-  pkg_experimental_webworker_runtime --> pkg_host_webserver
-  pkg_experimental_webworker_runtime --> pkg_invariants
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
   pkg_sdk_protocol --> pkg_subagent
-  pkg_webhook_github --> pkg_credentials
-  pkg_webhook_github --> pkg_host_webserver
-  pkg_webhook_github --> pkg_invariants
-  pkg_webhook_github --> pkg_session
-  pkg_webhook_github --> pkg_webhook
   pkg_tool_ralph --> pkg_agent
   pkg_tool_ralph --> pkg_invariants
   pkg_tool_ralph --> pkg_llm
@@ -1212,37 +1221,6 @@ flowchart TD
   pkg_subagent_spawn_in_process --> pkg_invariants
   pkg_subagent_spawn_in_process --> pkg_subagent
   pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver
-  pkg_api_gateway --> pkg_brand
-  pkg_api_gateway --> pkg_client_connection
-  pkg_api_gateway --> pkg_host_webserver
-  pkg_api_gateway --> pkg_invariants
-  pkg_api_gateway --> pkg_typert_registry
-  pkg_experimental_tool_agent_team --> pkg_agent
-  pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
-  pkg_experimental_tool_agent_team --> pkg_invariants
-  pkg_experimental_tool_agent_team --> pkg_session
-  pkg_experimental_tool_agent_team --> pkg_system_prompt
-  pkg_experimental_tool_agent_team --> pkg_tools
-  pkg_sdk_client --> pkg_invariants
-  pkg_sdk_client --> pkg_llm
-  pkg_sdk_client --> pkg_sdk_protocol
-  pkg_sdk_client --> pkg_session
-  pkg_sdk_jsonrpc_server --> pkg_agent
-  pkg_sdk_jsonrpc_server --> pkg_attachment
-  pkg_sdk_jsonrpc_server --> pkg_invariants
-  pkg_sdk_jsonrpc_server --> pkg_llm
-  pkg_sdk_jsonrpc_server --> pkg_llm_deepseek
-  pkg_sdk_jsonrpc_server --> pkg_scope
-  pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
-  pkg_sdk_jsonrpc_server --> pkg_session
-  pkg_sdk_jsonrpc_server --> pkg_subagent
-  pkg_subagent_dsh_sdk --> pkg_agent
-  pkg_subagent_dsh_sdk --> pkg_invariants
-  pkg_subagent_dsh_sdk --> pkg_llm
-  pkg_subagent_dsh_sdk --> pkg_sdk_client
-  pkg_subagent_dsh_sdk --> pkg_session
-  pkg_subagent_dsh_sdk --> pkg_subagent
-  pkg_subagent_dsh_sdk --> pkg_subprocess
   pkg_api_session_controller --> pkg_agent
   pkg_api_session_controller --> pkg_agent_default_model
   pkg_api_session_controller --> pkg_agent_presets
@@ -1272,6 +1250,32 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
+  pkg_experimental_tool_agent_team --> pkg_agent
+  pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
+  pkg_experimental_tool_agent_team --> pkg_invariants
+  pkg_experimental_tool_agent_team --> pkg_session
+  pkg_experimental_tool_agent_team --> pkg_system_prompt
+  pkg_experimental_tool_agent_team --> pkg_tools
+  pkg_sdk_client --> pkg_invariants
+  pkg_sdk_client --> pkg_llm
+  pkg_sdk_client --> pkg_sdk_protocol
+  pkg_sdk_client --> pkg_session
+  pkg_sdk_jsonrpc_server --> pkg_agent
+  pkg_sdk_jsonrpc_server --> pkg_attachment
+  pkg_sdk_jsonrpc_server --> pkg_invariants
+  pkg_sdk_jsonrpc_server --> pkg_llm
+  pkg_sdk_jsonrpc_server --> pkg_llm_deepseek
+  pkg_sdk_jsonrpc_server --> pkg_scope
+  pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
+  pkg_sdk_jsonrpc_server --> pkg_session
+  pkg_sdk_jsonrpc_server --> pkg_subagent
+  pkg_subagent_dsh_sdk --> pkg_agent
+  pkg_subagent_dsh_sdk --> pkg_invariants
+  pkg_subagent_dsh_sdk --> pkg_llm
+  pkg_subagent_dsh_sdk --> pkg_sdk_client
+  pkg_subagent_dsh_sdk --> pkg_session
+  pkg_subagent_dsh_sdk --> pkg_subagent
+  pkg_subagent_dsh_sdk --> pkg_subprocess
   pkg_api_remotes --> pkg_agent_presets
   pkg_api_remotes --> pkg_api_gateway
   pkg_api_remotes --> pkg_api_session_controller
@@ -1383,6 +1387,7 @@ flowchart TD
   pkg_client_ui_workspace --> pkg_invariants
   pkg_client_ui_workspace --> pkg_session
   pkg_client_ui_workspace --> pkg_util_workspace_path
+  pkg_client_ui_agent_preset --> pkg_agent_presets
   pkg_client_ui_agent_preset --> pkg_api_remotes
   pkg_client_ui_agent_preset --> pkg_api_session_controller
   pkg_client_ui_agent_preset --> pkg_client_connection
@@ -1794,7 +1799,7 @@ flowchart TD
 | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1810,8 +1815,7 @@ flowchart TD
 | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
@@ -1820,6 +1824,15 @@ flowchart TD
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
+| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
+| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
+| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1828,27 +1841,19 @@ flowchart TD
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
-| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
-| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
-| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
-| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
+| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
-| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
-| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
@@ -1862,7 +1867,7 @@ flowchart TD
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) |
-| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 129 - 124
docs/module-graph.zh.md

@@ -891,6 +891,7 @@ flowchart TD
   pkg_agent_presets --> pkg_invariants
   pkg_agent_presets --> pkg_scope
   pkg_agent_presets --> pkg_session
+  pkg_agent_presets --> pkg_session_projection
   pkg_agent_presets --> pkg_settings
   pkg_agent_presets --> pkg_system_prompt
   pkg_agent_presets --> pkg_tools
@@ -979,26 +980,13 @@ flowchart TD
   pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions
   pkg_plugin_package_inventory_deepseek --> pkg_invariants
   pkg_plugin_package_inventory_deepseek --> pkg_session
-  pkg_subagent --> pkg_agent
-  pkg_subagent --> pkg_agent_presets
-  pkg_subagent --> pkg_brand
-  pkg_subagent --> pkg_invariants
-  pkg_subagent --> pkg_jobs
-  pkg_subagent --> pkg_llm
-  pkg_subagent --> pkg_sandbox
-  pkg_subagent --> pkg_sandbox_policy
-  pkg_subagent --> pkg_scope
-  pkg_subagent --> pkg_session
-  pkg_subagent --> pkg_session_persistence
-  pkg_subagent --> pkg_session_projection
-  pkg_subagent --> pkg_session_projection_cache
-  pkg_subagent --> pkg_tools
-  pkg_subagent --> pkg_user_approval
   pkg_session_query --> pkg_brand
   pkg_session_query --> pkg_invariants
   pkg_session_query --> pkg_llm
   pkg_session_query --> pkg_session
   pkg_session_query --> pkg_session_persistence
+  pkg_session_query --> pkg_session_projection
+  pkg_session_query --> pkg_session_projection_cache
   pkg_session_query --> pkg_session_title
   pkg_session_query --> pkg_tool_todo
   pkg_acp --> pkg_agent
@@ -1060,60 +1048,22 @@ flowchart TD
   pkg_webhook --> pkg_session
   pkg_webhook --> pkg_session_title
   pkg_webhook --> pkg_workspace
-  pkg_subagent_acp --> pkg_agent
-  pkg_subagent_acp --> pkg_invariants
-  pkg_subagent_acp --> pkg_llm
-  pkg_subagent_acp --> pkg_session
-  pkg_subagent_acp --> pkg_subagent
-  pkg_subagent_acp --> pkg_subprocess
-  pkg_subagent_acp --> pkg_timeout
-  pkg_subagent_claude_code --> pkg_invariants
-  pkg_subagent_claude_code --> pkg_llm
-  pkg_subagent_claude_code --> pkg_session
-  pkg_subagent_claude_code --> pkg_subagent
-  pkg_subagent_claude_code --> pkg_subprocess
-  pkg_subagent_claude_code --> pkg_timeout
-  pkg_subagent_codex --> pkg_invariants
-  pkg_subagent_codex --> pkg_llm
-  pkg_subagent_codex --> pkg_session
-  pkg_subagent_codex --> pkg_subagent
-  pkg_subagent_codex --> pkg_subprocess
-  pkg_subagent_codex --> pkg_timeout
-  pkg_subagent_in_process_driver --> pkg_agent
-  pkg_subagent_in_process_driver --> pkg_invariants
-  pkg_subagent_in_process_driver --> pkg_llm
-  pkg_subagent_in_process_driver --> pkg_session
-  pkg_subagent_in_process_driver --> pkg_subagent
-  pkg_subagent_in_process_driver --> pkg_system_prompt
-  pkg_subagent_in_process_driver --> pkg_tools
-  pkg_tool_subagent --> pkg_agent
-  pkg_tool_subagent --> pkg_invariants
-  pkg_tool_subagent --> pkg_jobs
-  pkg_tool_subagent --> pkg_llm
-  pkg_tool_subagent --> pkg_scope
-  pkg_tool_subagent --> pkg_session
-  pkg_tool_subagent --> pkg_settings
-  pkg_tool_subagent --> pkg_subagent
-  pkg_tool_subagent --> pkg_system_prompt
-  pkg_tool_subagent --> pkg_tools
-  pkg_tool_subagent_control --> pkg_invariants
-  pkg_tool_subagent_control --> pkg_llm
-  pkg_tool_subagent_control --> pkg_session
-  pkg_tool_subagent_control --> pkg_subagent
-  pkg_tool_subagent_control --> pkg_tools
-  pkg_tool_subagent_report --> pkg_invariants
-  pkg_tool_subagent_report --> pkg_llm
-  pkg_tool_subagent_report --> pkg_subagent
-  pkg_tool_subagent_report --> pkg_system_prompt
-  pkg_tool_subagent_report --> pkg_tools
-  pkg_hooks_claude_code --> pkg_agent
-  pkg_hooks_claude_code --> pkg_hook_protocol
-  pkg_hooks_claude_code --> pkg_invariants
-  pkg_hooks_claude_code --> pkg_llm
-  pkg_hooks_claude_code --> pkg_session
-  pkg_hooks_claude_code --> pkg_session_persistence
-  pkg_hooks_claude_code --> pkg_subagent
-  pkg_hooks_claude_code --> pkg_tools
+  pkg_subagent --> pkg_agent
+  pkg_subagent --> pkg_agent_presets
+  pkg_subagent --> pkg_brand
+  pkg_subagent --> pkg_invariants
+  pkg_subagent --> pkg_jobs
+  pkg_subagent --> pkg_llm
+  pkg_subagent --> pkg_sandbox
+  pkg_subagent --> pkg_sandbox_policy
+  pkg_subagent --> pkg_scope
+  pkg_subagent --> pkg_session
+  pkg_subagent --> pkg_session_persistence
+  pkg_subagent --> pkg_session_projection
+  pkg_subagent --> pkg_session_projection_cache
+  pkg_subagent --> pkg_session_query
+  pkg_subagent --> pkg_tools
+  pkg_subagent --> pkg_user_approval
   pkg_session_query_sqlite --> pkg_invariants
   pkg_session_query_sqlite --> pkg_session
   pkg_session_query_sqlite --> pkg_session_persistence
@@ -1171,6 +1121,74 @@ flowchart TD
   pkg_agent_spine_demo --> pkg_tool_jobs
   pkg_agent_spine_demo --> pkg_tool_skill
   pkg_agent_spine_demo --> pkg_tools
+  pkg_experimental_webworker_runtime --> pkg_client_modules
+  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
+  pkg_experimental_webworker_runtime --> pkg_host_webserver
+  pkg_experimental_webworker_runtime --> pkg_invariants
+  pkg_webhook_github --> pkg_credentials
+  pkg_webhook_github --> pkg_host_webserver
+  pkg_webhook_github --> pkg_invariants
+  pkg_webhook_github --> pkg_session
+  pkg_webhook_github --> pkg_webhook
+  pkg_subagent_acp --> pkg_agent
+  pkg_subagent_acp --> pkg_invariants
+  pkg_subagent_acp --> pkg_llm
+  pkg_subagent_acp --> pkg_session
+  pkg_subagent_acp --> pkg_subagent
+  pkg_subagent_acp --> pkg_subprocess
+  pkg_subagent_acp --> pkg_timeout
+  pkg_subagent_claude_code --> pkg_invariants
+  pkg_subagent_claude_code --> pkg_llm
+  pkg_subagent_claude_code --> pkg_session
+  pkg_subagent_claude_code --> pkg_subagent
+  pkg_subagent_claude_code --> pkg_subprocess
+  pkg_subagent_claude_code --> pkg_timeout
+  pkg_subagent_codex --> pkg_invariants
+  pkg_subagent_codex --> pkg_llm
+  pkg_subagent_codex --> pkg_session
+  pkg_subagent_codex --> pkg_subagent
+  pkg_subagent_codex --> pkg_subprocess
+  pkg_subagent_codex --> pkg_timeout
+  pkg_subagent_in_process_driver --> pkg_agent
+  pkg_subagent_in_process_driver --> pkg_invariants
+  pkg_subagent_in_process_driver --> pkg_llm
+  pkg_subagent_in_process_driver --> pkg_session
+  pkg_subagent_in_process_driver --> pkg_subagent
+  pkg_subagent_in_process_driver --> pkg_system_prompt
+  pkg_subagent_in_process_driver --> pkg_tools
+  pkg_tool_subagent --> pkg_agent
+  pkg_tool_subagent --> pkg_invariants
+  pkg_tool_subagent --> pkg_jobs
+  pkg_tool_subagent --> pkg_llm
+  pkg_tool_subagent --> pkg_scope
+  pkg_tool_subagent --> pkg_session
+  pkg_tool_subagent --> pkg_settings
+  pkg_tool_subagent --> pkg_subagent
+  pkg_tool_subagent --> pkg_system_prompt
+  pkg_tool_subagent --> pkg_tools
+  pkg_tool_subagent_control --> pkg_invariants
+  pkg_tool_subagent_control --> pkg_llm
+  pkg_tool_subagent_control --> pkg_session
+  pkg_tool_subagent_control --> pkg_subagent
+  pkg_tool_subagent_control --> pkg_tools
+  pkg_tool_subagent_report --> pkg_invariants
+  pkg_tool_subagent_report --> pkg_llm
+  pkg_tool_subagent_report --> pkg_subagent
+  pkg_tool_subagent_report --> pkg_system_prompt
+  pkg_tool_subagent_report --> pkg_tools
+  pkg_hooks_claude_code --> pkg_agent
+  pkg_hooks_claude_code --> pkg_hook_protocol
+  pkg_hooks_claude_code --> pkg_invariants
+  pkg_hooks_claude_code --> pkg_llm
+  pkg_hooks_claude_code --> pkg_session
+  pkg_hooks_claude_code --> pkg_session_persistence
+  pkg_hooks_claude_code --> pkg_subagent
+  pkg_hooks_claude_code --> pkg_tools
+  pkg_api_gateway --> pkg_brand
+  pkg_api_gateway --> pkg_client_connection
+  pkg_api_gateway --> pkg_host_webserver
+  pkg_api_gateway --> pkg_invariants
+  pkg_api_gateway --> pkg_typert_registry
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_brand
   pkg_experimental_agent_team --> pkg_invariants
@@ -1178,19 +1196,10 @@ flowchart TD
   pkg_experimental_agent_team --> pkg_session
   pkg_experimental_agent_team --> pkg_session_persistence
   pkg_experimental_agent_team --> pkg_subagent
-  pkg_experimental_webworker_runtime --> pkg_client_modules
-  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
-  pkg_experimental_webworker_runtime --> pkg_host_webserver
-  pkg_experimental_webworker_runtime --> pkg_invariants
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
   pkg_sdk_protocol --> pkg_subagent
-  pkg_webhook_github --> pkg_credentials
-  pkg_webhook_github --> pkg_host_webserver
-  pkg_webhook_github --> pkg_invariants
-  pkg_webhook_github --> pkg_session
-  pkg_webhook_github --> pkg_webhook
   pkg_tool_ralph --> pkg_agent
   pkg_tool_ralph --> pkg_invariants
   pkg_tool_ralph --> pkg_llm
@@ -1214,37 +1223,6 @@ flowchart TD
   pkg_subagent_spawn_in_process --> pkg_invariants
   pkg_subagent_spawn_in_process --> pkg_subagent
   pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver
-  pkg_api_gateway --> pkg_brand
-  pkg_api_gateway --> pkg_client_connection
-  pkg_api_gateway --> pkg_host_webserver
-  pkg_api_gateway --> pkg_invariants
-  pkg_api_gateway --> pkg_typert_registry
-  pkg_experimental_tool_agent_team --> pkg_agent
-  pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
-  pkg_experimental_tool_agent_team --> pkg_invariants
-  pkg_experimental_tool_agent_team --> pkg_session
-  pkg_experimental_tool_agent_team --> pkg_system_prompt
-  pkg_experimental_tool_agent_team --> pkg_tools
-  pkg_sdk_client --> pkg_invariants
-  pkg_sdk_client --> pkg_llm
-  pkg_sdk_client --> pkg_sdk_protocol
-  pkg_sdk_client --> pkg_session
-  pkg_sdk_jsonrpc_server --> pkg_agent
-  pkg_sdk_jsonrpc_server --> pkg_attachment
-  pkg_sdk_jsonrpc_server --> pkg_invariants
-  pkg_sdk_jsonrpc_server --> pkg_llm
-  pkg_sdk_jsonrpc_server --> pkg_llm_deepseek
-  pkg_sdk_jsonrpc_server --> pkg_scope
-  pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
-  pkg_sdk_jsonrpc_server --> pkg_session
-  pkg_sdk_jsonrpc_server --> pkg_subagent
-  pkg_subagent_dsh_sdk --> pkg_agent
-  pkg_subagent_dsh_sdk --> pkg_invariants
-  pkg_subagent_dsh_sdk --> pkg_llm
-  pkg_subagent_dsh_sdk --> pkg_sdk_client
-  pkg_subagent_dsh_sdk --> pkg_session
-  pkg_subagent_dsh_sdk --> pkg_subagent
-  pkg_subagent_dsh_sdk --> pkg_subprocess
   pkg_api_session_controller --> pkg_agent
   pkg_api_session_controller --> pkg_agent_default_model
   pkg_api_session_controller --> pkg_agent_presets
@@ -1274,6 +1252,32 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
+  pkg_experimental_tool_agent_team --> pkg_agent
+  pkg_experimental_tool_agent_team --> pkg_experimental_agent_team
+  pkg_experimental_tool_agent_team --> pkg_invariants
+  pkg_experimental_tool_agent_team --> pkg_session
+  pkg_experimental_tool_agent_team --> pkg_system_prompt
+  pkg_experimental_tool_agent_team --> pkg_tools
+  pkg_sdk_client --> pkg_invariants
+  pkg_sdk_client --> pkg_llm
+  pkg_sdk_client --> pkg_sdk_protocol
+  pkg_sdk_client --> pkg_session
+  pkg_sdk_jsonrpc_server --> pkg_agent
+  pkg_sdk_jsonrpc_server --> pkg_attachment
+  pkg_sdk_jsonrpc_server --> pkg_invariants
+  pkg_sdk_jsonrpc_server --> pkg_llm
+  pkg_sdk_jsonrpc_server --> pkg_llm_deepseek
+  pkg_sdk_jsonrpc_server --> pkg_scope
+  pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
+  pkg_sdk_jsonrpc_server --> pkg_session
+  pkg_sdk_jsonrpc_server --> pkg_subagent
+  pkg_subagent_dsh_sdk --> pkg_agent
+  pkg_subagent_dsh_sdk --> pkg_invariants
+  pkg_subagent_dsh_sdk --> pkg_llm
+  pkg_subagent_dsh_sdk --> pkg_sdk_client
+  pkg_subagent_dsh_sdk --> pkg_session
+  pkg_subagent_dsh_sdk --> pkg_subagent
+  pkg_subagent_dsh_sdk --> pkg_subprocess
   pkg_api_remotes --> pkg_agent_presets
   pkg_api_remotes --> pkg_api_gateway
   pkg_api_remotes --> pkg_api_session_controller
@@ -1385,6 +1389,7 @@ flowchart TD
   pkg_client_ui_workspace --> pkg_invariants
   pkg_client_ui_workspace --> pkg_session
   pkg_client_ui_workspace --> pkg_util_workspace_path
+  pkg_client_ui_agent_preset --> pkg_agent_presets
   pkg_client_ui_agent_preset --> pkg_api_remotes
   pkg_client_ui_agent_preset --> pkg_api_session_controller
   pkg_client_ui_agent_preset --> pkg_client_connection
@@ -1796,7 +1801,7 @@ flowchart TD
 | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1812,8 +1817,7 @@ flowchart TD
 | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
@@ -1822,6 +1826,15 @@ flowchart TD
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
+| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
+| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
+| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1830,27 +1843,19 @@ flowchart TD
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
-| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
-| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
-| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
-| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
+| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
-| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
-| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
@@ -1864,7 +1869,7 @@ flowchart TD
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) |
-| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 2 - 2
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: dd2124520e43e590fc3506b23c533b3e482132cd
-persistence-catalog.zh.md: 48c0867f37fc87ce5d29ce04b0b6970fca83648c
+persistence-catalog.md: 893ffef71be98afe2356419dcb6ca0d871f26649
+persistence-catalog.zh.md: e34ce2b4b67746f9ce79f3d61add5e7f59e1aa22

+ 17 - 1
docs/persistence-catalog.md

@@ -133,7 +133,7 @@ Source: [`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types
 'agent-preset/selected': { agentPreset: string }
 ```
 
-Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts)
+Source: [`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts)
 
 ### `approval/*`
 
@@ -498,6 +498,22 @@ Source: [`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/
 
 Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts)
 
+### `model/*`
+
+<a id="modelselection--log-only"></a>
+
+#### `model/selection` — log-only
+
+```ts persistence-catalog
+/**
+ * Complete validated model selection requested for subsequent prompt
+ * assembly. Log-only: it never enters derived model history.
+ */
+'model/selection': ModelSelection
+```
+
+Source: [`packages/api/session-controller/src/types.ts:39`](../packages/api/session-controller/src/types.ts)
+
 ### `permission/*`
 
 <a id="permissionpreset--log-only"></a>

+ 17 - 1
docs/persistence-catalog.zh.md

@@ -135,7 +135,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'agent-preset/selected': { agentPreset: string }
 ```
 
-来源:[`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts)
+来源:[`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts)
 
 ### `approval/*`
 
@@ -500,6 +500,22 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 来源:[`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts)
 
+### `model/*`
+
+<a id="modelselection--log-only"></a>
+
+#### `model/selection` — log-only
+
+```ts persistence-catalog
+/**
+ * Complete validated model selection requested for subsequent prompt
+ * assembly. Log-only: it never enters derived model history.
+ */
+'model/selection': ModelSelection
+```
+
+来源:[`packages/api/session-controller/src/types.ts:39`](../packages/api/session-controller/src/types.ts)
+
 ### `permission/*`
 
 <a id="permissionpreset--log-only"></a>

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

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

+ 11 - 0
docs/subsystems/persistence.md

@@ -347,6 +347,17 @@ abstract load(id: SessionId): Promise<SessionInspection>
  */
 abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
 
+/**
+ * Borrow one exact inspection while retaining any reusable prepared source.
+ * A cold observation must pin the exact prepared Session that a later
+ * {@link prepare} reserves. Implementations must not degrade this operation
+ * to a detached {@link inspect} result.
+ * @param id - persisted session to observe.
+ * @param signal - optional cancellation for preparation work.
+ * @returns a disposable immutable observation.
+ */
+abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise<BorrowedSessionSource>
+
 /**
  * Read the stored events from `fromSeq` onward — the read-from-seq
  * primitive for read models that resume from a watermark (e.g. a persisted

+ 11 - 0
docs/subsystems/persistence.zh.md

@@ -347,6 +347,17 @@ abstract load(id: SessionId): Promise<SessionInspection>
  */
 abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
 
+/**
+ * Borrow one exact inspection while retaining any reusable prepared source.
+ * A cold observation must pin the exact prepared Session that a later
+ * {@link prepare} reserves. Implementations must not degrade this operation
+ * to a detached {@link inspect} result.
+ * @param id - persisted session to observe.
+ * @param signal - optional cancellation for preparation work.
+ * @returns a disposable immutable observation.
+ */
+abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise<BorrowedSessionSource>
+
 /**
  * Read the stored events from `fromSeq` onward — the read-from-seq
  * primitive for read models that resume from a watermark (e.g. a persisted

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

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

+ 50 - 10
docs/subsystems/session-projection.md

@@ -28,10 +28,11 @@ interface ProjectionDefinition<
   /** Validates persisted state before it seeds a fold. */
   stateSchema: ZodType<S>
   /**
-   * State for the empty log.
+   * State for the empty log and its immutable Session metadata.
+   * @param header - immutable metadata for the Session being projected.
    * @returns the initial state.
    */
-  init(): NoInfer<S>
+  init(header: SessionHeader): NoInfer<S>
   /**
    * Pure transition: previous state + one committed event → next state. A
    * unit uninterested in an event MUST return the same state reference — an
@@ -124,10 +125,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  * paths (the history tail baseline, {@link coldSnapshot}) supersede these
  * values whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
+ * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
+cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
+
+/**
+ * Hydrate projection cells for an already-prepared Session without another
+ * persistence read. The cache seeds matching rows; the supplied exact log
+ * advances every unit to the observation cut. No checkpoint is written
+ * because the logical observation may contain recovery events not yet durable.
+ * @param session - exact unpublished Session retained by persistence.
+ * @param meta - observed lifecycle header.
+ * @param events - exact logical event prefix represented by the observation.
+ * @returns all projection values at the event cut.
+ */
+hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot
 
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
@@ -154,7 +168,7 @@ async write(session: Session): Promise<void>
 async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
 ```
 
-Types: [Session](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 
@@ -192,7 +206,8 @@ register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjec
 onChanged(listener: ProjectionChangeListener): () => void
 
 /**
- * Read one unit's current host state without computing unrelated views.
+ * Read one unit's current host state after materializing every registered
+ * unit at the Session cursor. Unrelated wire views are not produced.
  * The returned value is live; callers must not mutate it.
  * @param session - the session whose state is read.
  * @param key - the registered unit key.
@@ -206,9 +221,20 @@ stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ):
  * Fully synchronous — every value and `asOfSeq` reflect the same log
  * position. Each value passes its unit's `viewSchema` before leaving.
  * @param session - the session whose projection values are read.
- * @returns the snapshot; `values` is empty when no client-visible unit is registered.
+ * @param keys - optional client-visible outputs; state materialization remains complete.
+ * @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
  */
-snapshot(session: Session): ProjectionSnapshot
+snapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot
+
+/**
+ * Read only already-materialized client-visible cells without folding history.
+ * Values may trail the live Session and are therefore hints, not a complete
+ * baseline. Missing cells are omitted.
+ * @param session - attached Session whose cached cells are inspected.
+ * @param keys - optional wire keys to view.
+ * @returns the lowest common cached cut, or `undefined` when no wire cell exists.
+ */
+cachedSnapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
 
 /**
  * State-level checkpoint of every persisted unit for one session, read
@@ -252,9 +278,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
  * fuller read path refolds it). The zero-I/O rung of the read ladder —
  * values are as stale as their rows, never wrong.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
+ * @param keys - optional wire keys to view.
  * @returns whole values per key with a usable row; empty when none.
  */
-viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
+viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): Partial<SessionProjectionMap>
 
 /**
  * Cold read: fold every persisted unit over a stored log suffix, seeding
@@ -274,14 +301,27 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @param events - the stored events with `seq >= baseSeq`, in seq order.
  * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
+ * @param header - immutable metadata for the Session being restored.
  * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
  *   supplied event's seq, `baseSeq - 1` for an empty tail) plus the
  *   refreshed checkpoint rows at that cut, ready for a durable write-back.
  */
-restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+
+/**
+ * Restore an exact cut and install its states on the supplied prepared Session.
+ * A later publication reuses these cells; ordinary live reads and event drive
+ * advance any constructor-owned suffix exactly once.
+ * @param session - exact prepared Session that owns the restored log prefix.
+ * @param checkpoint - persisted rows for this Session lifecycle.
+ * @param events - exact events at the observation cut.
+ * @param baseSeq - first supplied event sequence.
+ * @returns all projection values at the supplied cut.
+ */
+hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot
 ```
 
-Types: [Session](session.md) · [SessionEvent](session.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md)
 
 Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 50 - 10
docs/subsystems/session-projection.zh.md

@@ -28,10 +28,11 @@ interface ProjectionDefinition<
   /** Validates persisted state before it seeds a fold. */
   stateSchema: ZodType<S>
   /**
-   * State for the empty log.
+   * State for the empty log and its immutable Session metadata.
+   * @param header - immutable metadata for the Session being projected.
    * @returns the initial state.
    */
-  init(): NoInfer<S>
+  init(header: SessionHeader): NoInfer<S>
   /**
    * Pure transition: previous state + one committed event → next state. A
    * unit uninterested in an event MUST return the same state reference — an
@@ -124,10 +125,23 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  * paths (the history tail baseline, {@link coldSnapshot}) supersede these
  * values whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
+ * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
+cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
+
+/**
+ * Hydrate projection cells for an already-prepared Session without another
+ * persistence read. The cache seeds matching rows; the supplied exact log
+ * advances every unit to the observation cut. No checkpoint is written
+ * because the logical observation may contain recovery events not yet durable.
+ * @param session - exact unpublished Session retained by persistence.
+ * @param meta - observed lifecycle header.
+ * @param events - exact logical event prefix represented by the observation.
+ * @returns all projection values at the event cut.
+ */
+hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot
 
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
@@ -154,7 +168,7 @@ async write(session: Session): Promise<void>
 async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
 ```
 
-Types: [Session](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md)
+Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 
@@ -192,7 +206,8 @@ register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjec
 onChanged(listener: ProjectionChangeListener): () => void
 
 /**
- * Read one unit's current host state without computing unrelated views.
+ * Read one unit's current host state after materializing every registered
+ * unit at the Session cursor. Unrelated wire views are not produced.
  * The returned value is live; callers must not mutate it.
  * @param session - the session whose state is read.
  * @param key - the registered unit key.
@@ -206,9 +221,20 @@ stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ):
  * Fully synchronous — every value and `asOfSeq` reflect the same log
  * position. Each value passes its unit's `viewSchema` before leaving.
  * @param session - the session whose projection values are read.
- * @returns the snapshot; `values` is empty when no client-visible unit is registered.
+ * @param keys - optional client-visible outputs; state materialization remains complete.
+ * @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
  */
-snapshot(session: Session): ProjectionSnapshot
+snapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot
+
+/**
+ * Read only already-materialized client-visible cells without folding history.
+ * Values may trail the live Session and are therefore hints, not a complete
+ * baseline. Missing cells are omitted.
+ * @param session - attached Session whose cached cells are inspected.
+ * @param keys - optional wire keys to view.
+ * @returns the lowest common cached cut, or `undefined` when no wire cell exists.
+ */
+cachedSnapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
 
 /**
  * State-level checkpoint of every persisted unit for one session, read
@@ -252,9 +278,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
  * fuller read path refolds it). The zero-I/O rung of the read ladder —
  * values are as stale as their rows, never wrong.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
+ * @param keys - optional wire keys to view.
  * @returns whole values per key with a usable row; empty when none.
  */
-viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
+viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): Partial<SessionProjectionMap>
 
 /**
  * Cold read: fold every persisted unit over a stored log suffix, seeding
@@ -274,14 +301,27 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @param events - the stored events with `seq >= baseSeq`, in seq order.
  * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
+ * @param header - immutable metadata for the Session being restored.
  * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
  *   supplied event's seq, `baseSeq - 1` for an empty tail) plus the
  *   refreshed checkpoint rows at that cut, ready for a durable write-back.
  */
-restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+
+/**
+ * Restore an exact cut and install its states on the supplied prepared Session.
+ * A later publication reuses these cells; ordinary live reads and event drive
+ * advance any constructor-owned suffix exactly once.
+ * @param session - exact prepared Session that owns the restored log prefix.
+ * @param checkpoint - persisted rows for this Session lifecycle.
+ * @param events - exact events at the observation cut.
+ * @param baseSeq - first supplied event sequence.
+ * @returns all projection values at the supplied cut.
+ */
+hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot
 ```
 
-Types: [Session](session.zh.md) · [SessionEvent](session.zh.md)
+Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md)
 
 Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 2 - 2
docs/subsystems/session-query.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-query.md
-session-query.md: 40dbd63cb8f0b6922130cb855cc313ebee73150a
-session-query.zh.md: 7ccda38af1117b3ab9d2f55fde910c6d338c31f2
+session-query.md: 5f897cfe28983ca3d932291ede904cff237583cc
+session-query.zh.md: 7d63210f51fe07710f19434b55436935086a8c29

+ 8 - 0
docs/subsystems/session-query.md

@@ -373,6 +373,14 @@ Unified live-preferred session query service.
 Exact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service.
 
 ```ts cordis-catalog
+/**
+ * Observe one exact live or prepared Session without a persistence listing preflight.
+ * @param sessionId - logical Session identity.
+ * @param options - cancellation and projection selection for this read.
+ * @returns a caller-owned observation lease.
+ */
+observeSession( sessionId: SessionId, options: SessionObservationOptions = {}, ): Promise<SessionObservation>
+
 /**
  * Search the live-preferred logical corpus and group by session.
  * @param request - query text, metadata filters, page size, and cursor.

+ 8 - 0
docs/subsystems/session-query.zh.md

@@ -373,6 +373,14 @@ Unified live-preferred session query service.
 Exact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service.
 
 ```ts cordis-catalog
+/**
+ * Observe one exact live or prepared Session without a persistence listing preflight.
+ * @param sessionId - logical Session identity.
+ * @param options - cancellation and projection selection for this read.
+ * @returns a caller-owned observation lease.
+ */
+observeSession( sessionId: SessionId, options: SessionObservationOptions = {}, ): Promise<SessionObservation>
+
 /**
  * Search the live-preferred logical corpus and group by session.
  * @param request - query text, metadata filters, page size, and cursor.

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

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

+ 2 - 9
docs/subsystems/session.md

@@ -636,13 +636,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  */
 @Remote('create') create(request: SessionCreateRequest): Promise<SessionCreateValue>
 
-/**
- * Read model choices after explicitly resuming the addressed Session.
- * @param request - Session whose model state is requested.
- * @returns the current selection and available model groups.
- */
-@Remote('models') models(request: SessionModelsRequest): Promise<SessionModels>
-
 /**
  * Select one Session-local model after explicitly resuming the Session.
  * @param request - Session identity and requested model selection.
@@ -697,7 +690,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  * Read one cold-safe, message-aligned Session history page.
  * @param request - durable address, backward cursor, and page budget.
  * @param signal - cancellation for persistence reads.
- * @returns one chronological page and optional latest projections.
+ * @returns one chronological page.
  */
 @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
 
@@ -705,7 +698,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  * Follow one Session log from its opening or resume cursor.
  * @param request - durable address and last committed sequence already held by the caller.
  * @param signal - cancellation owned by the Remote stream carrier.
- * @returns an opened cursor followed by gap-free event frames.
+ * @returns a complete opening snapshot followed by gap-free event frames.
  */
 @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
 

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

@@ -640,13 +640,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  */
 @Remote('create') create(request: SessionCreateRequest): Promise<SessionCreateValue>
 
-/**
- * Read model choices after explicitly resuming the addressed Session.
- * @param request - Session whose model state is requested.
- * @returns the current selection and available model groups.
- */
-@Remote('models') models(request: SessionModelsRequest): Promise<SessionModels>
-
 /**
  * Select one Session-local model after explicitly resuming the Session.
  * @param request - Session identity and requested model selection.
@@ -701,7 +694,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  * Read one cold-safe, message-aligned Session history page.
  * @param request - durable address, backward cursor, and page budget.
  * @param signal - cancellation for persistence reads.
- * @returns one chronological page and optional latest projections.
+ * @returns one chronological page.
  */
 @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
 
@@ -709,7 +702,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
  * Follow one Session log from its opening or resume cursor.
  * @param request - durable address and last committed sequence already held by the caller.
  * @param signal - cancellation owned by the Remote stream carrier.
- * @returns an opened cursor followed by gap-free event frames.
+ * @returns a complete opening snapshot followed by gap-free event frames.
  */
 @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
 

+ 2 - 2
docs/subsystems/subagent.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/subagent.md
-subagent.md: 63edef0a4b8d5368ea9d6d82f0ea3ef99ea0bbad
-subagent.zh.md: 21b0dfd21dbee5e1d37558d6c02fe7949126d9a9
+subagent.md: 8c26177bb2c534cfe724a3efb3861fb8a303b731
+subagent.zh.md: 9cdf55d5d8c9e8bec8ab93a541f2b80da4655c12

+ 7 - 18
docs/subsystems/subagent.md

@@ -606,27 +606,16 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P
 
 /**
  * Enumerate the parent's direct session-backed subagents without loading or
- * resuming an Agent and without any query service: the listing merges the live
- * session store with optional session persistence (live-preferred) and
- * serves each child's durable mode/label from the registered `subagent`
- * projection unit down a three-rung ladder — the registry's watermark
- * snapshot for a live child; for a cold one, a durable projection-cache
- * row when the optional cache serves an own-suffix identity (its `seq`
- * gate proves the value postdates the fork seed, where a child's own
- * descriptor is immutable once appended), else one persistence inspection
- * folded through the registry. The
- * projection fold is the single classification authority; per-child
- * diagnostics relay a fold that served no identity or a failed inspection,
- * never a list-time descriptor parse. Absent persistence, enumeration is
- * live-only (a cold child cannot be resumed then either, so its absence is
- * capability absence, not an error). This service consults no Agent
- * registrations, Activations, or providers.
+ * resuming an Agent. The Session query service supplies one live-preferred
+ * corpus and shared point observations; the projection cache supplies
+ * immutable descriptor hits without opening cold logs. The registered
+ * `subagent` projection remains the sole mode/label classifier.
  *
- * Every persistence read receives `signal`, and the listing rechecks
- * cancellation around each of those awaits. Read rejections that settle
+ * Every query receives `signal`, and the listing rechecks cancellation
+ * around each await. Read rejections that settle
  * after an abort become a stable `SubagentError` with code `CANCELLED`.
  * @param parentSessionId - parent session whose direct children are listed.
- * @param signal - caller-owned cancellation forwarded to persistence reads
+ * @param signal - caller-owned cancellation forwarded to Session queries
  *   and observed around every read await.
  * @returns children and per-child diagnostics ordered by `createdAt`, then id.
  * @throws {@link SubagentError} when the projection registry or the session

+ 7 - 18
docs/subsystems/subagent.zh.md

@@ -610,27 +610,16 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P
 
 /**
  * Enumerate the parent's direct session-backed subagents without loading or
- * resuming an Agent and without any query service: the listing merges the live
- * session store with optional session persistence (live-preferred) and
- * serves each child's durable mode/label from the registered `subagent`
- * projection unit down a three-rung ladder — the registry's watermark
- * snapshot for a live child; for a cold one, a durable projection-cache
- * row when the optional cache serves an own-suffix identity (its `seq`
- * gate proves the value postdates the fork seed, where a child's own
- * descriptor is immutable once appended), else one persistence inspection
- * folded through the registry. The
- * projection fold is the single classification authority; per-child
- * diagnostics relay a fold that served no identity or a failed inspection,
- * never a list-time descriptor parse. Absent persistence, enumeration is
- * live-only (a cold child cannot be resumed then either, so its absence is
- * capability absence, not an error). This service consults no Agent
- * registrations, Activations, or providers.
+ * resuming an Agent. The Session query service supplies one live-preferred
+ * corpus and shared point observations; the projection cache supplies
+ * immutable descriptor hits without opening cold logs. The registered
+ * `subagent` projection remains the sole mode/label classifier.
  *
- * Every persistence read receives `signal`, and the listing rechecks
- * cancellation around each of those awaits. Read rejections that settle
+ * Every query receives `signal`, and the listing rechecks cancellation
+ * around each await. Read rejections that settle
  * after an abort become a stable `SubagentError` with code `CANCELLED`.
  * @param parentSessionId - parent session whose direct children are listed.
- * @param signal - caller-owned cancellation forwarded to persistence reads
+ * @param signal - caller-owned cancellation forwarded to Session queries
  *   and observed around every read await.
  * @returns children and per-child diagnostics ordered by `createdAt`, then id.
  * @throws {@link SubagentError} when the projection registry or the session

+ 2 - 2
docs/subsystems/web-client.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/web-client.md
-web-client.md: 40902c273e2daafb5ea8acf2aefb0ce2a418b3f4
-web-client.zh.md: 79452e73c7ed6ed89df834995291c8f0e44d8258
+web-client.md: a06f6aaf45a482437b0509e65b6ee8332ec35a44
+web-client.zh.md: 09f25bc45369a34ebf32c7a0b2b995c6732c9b29

+ 2 - 2
docs/subsystems/web-client.md

@@ -43,7 +43,7 @@ Each API controller package owns a paired Host and Client face. The Host side ow
 - `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
 - Each `Session` owns one contiguous event window, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
 
-The durable event path opens `follow()` before reading the first page. A page establishes a contiguous window; live events append by sequence; older pages prepend without replacing unrelated objects. A gap or a new physical generation reads a fresh tail through the opening cursor before publishing a replacement. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
+The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. Each physical generation atomically replaces the retained window from that snapshot; live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
 
 ### Workspaces
 
@@ -75,7 +75,7 @@ Physical and logical recovery are separate. Gateway mux restores the physical We
 
 Recovery follows the data's semantics:
 
-- A durable Session journal resumes from the last accepted sequence and repairs the loaded window against a tail page before accepting later events.
+- A durable Session journal replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later sequence gap.
 - Session control and Workspace streams retain the last published value while disconnected, then atomically replace it from a fresh opening baseline.
 - Ordinary forwarded notifications are not replayed. Stateful domains need a baseline, cursor, or explicit query; scoped waterfalls retain their own request lifetime.
 

+ 2 - 2
docs/subsystems/web-client.zh.md

@@ -43,7 +43,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
 - `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
 - 每个 `Session` 拥有一段连续 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
 
-持久 event 路径会先打开 `follow()`,再读取第一页。page 建立连续窗口;实时 event 按 seq append;旧 page prepend 时不替换无关对象。遇到 gap 或新的物理 generation 时,模型先通过 opening cursor 读取新 tail,再发布 replacement。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
+持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
 
 ### Workspaces
 
@@ -75,7 +75,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
 
 恢复方式由数据语义决定:
 
-- 持久 Session journal 从最后接受的 seq 继续,并在接受后续 event 前依据 tail page 修复已加载窗口
+- 持久 Session journal 根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 seq gap
 - Session control 与 Workspace stream 在断开期间保留最后一次发布的值,再用新的 opening baseline 原子替换。
 - 普通 forwarded notification 不会 replay。需要可靠恢复的 stateful domain 必须提供 baseline、cursor 或显式 query;scoped waterfall 保留自身的 request lifetime。
 

+ 59 - 61
packages/api/gateway/src/client/journal-stream.ts

@@ -7,9 +7,9 @@ import type {
   RemoteStreamOptions,
 } from './remote-stream.ts'
 
-/** Transport-neutral opening cursor or journal entry. */
-export type RemoteJournalFrame<Entry, Cursor> =
-  | { readonly type: 'opened'; readonly cursor: Cursor }
+/** Transport-neutral opening snapshot or journal entry. */
+export type RemoteJournalFrame<Entry, Cursor, Page> =
+  | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page }
   | { readonly type: 'entry'; readonly entry: Entry }
 
 /** One committed journal-window update. */
@@ -28,7 +28,7 @@ export type RemoteJournalChange<Page, Entry> =
   }
   | { readonly type: 'append'; readonly entry: Entry }
 
-type JournalStreamItem<Entry, Cursor> = RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>
+type JournalStreamItem<Page, Entry, Cursor> = RemoteStreamItem<RemoteJournalFrame<Entry, Cursor, Page>>
 
 /** Gateway capability used to create one reconnecting Remote stream. */
 export interface RemoteStreamFactory {
@@ -65,13 +65,13 @@ export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
 }
 
 /**
- * Owns follow-before-page opening, ordered live delivery, pagination, and repair.
+ * Owns snapshot-first opening, ordered live delivery, pagination, and repair.
  *
  * The domain retains its published window during reconnection. A replacement is
- * published only after a tail page reaches the generation's opening cursor.
+ * published only after the opening page reaches the generation's cursor.
  */
 export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = void> {
-  private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor>>
+  private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor, Page>>
   private initialRequest!: PageRequest
   private resumeCursor: Cursor | undefined
   private hasResumeCursor = false
@@ -83,7 +83,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   private disposed = false
   private done: Promise<void> | undefined
   private closing: Promise<void> | undefined
-  private pendingNext: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> | undefined
+  private pendingNext: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> | undefined
 
   /**
    * @param remote - Gateway factory for the reconnecting physical-generation stream.
@@ -93,12 +93,9 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     remote: RemoteStreamFactory,
     private readonly options: RemoteJournalStreamOptions<Page, Entry, Cursor>,
   ) {
-    this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor>>({
+    this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor, Page>>({
       name: options.name,
-      open: signal => this.follow(
-        this.hasResumeCursor ? this.resumeCursor : undefined,
-        signal,
-      ),
+      open: signal => this.follow(this.initialRequest, signal),
       ended: accepted => accepted
         ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`)
         : new Error(
@@ -111,15 +108,15 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   /**
-   * Open one physical journal generation after the last accepted cursor.
-   * @param after - last accepted cursor, or `undefined` for the initial generation.
+   * Open one physical journal generation with a complete current snapshot.
+   * @param request - opening-window request retained for later repair.
    * @param signal - cancellation lifetime of the physical generation.
    * @returns opening cursor followed by live entries.
    */
   protected abstract follow(
-    after: Cursor | undefined,
+    request: PageRequest,
     signal: AbortSignal,
-  ): AsyncIterable<RemoteJournalFrame<Entry, Cursor>>
+  ): AsyncIterable<RemoteJournalFrame<Entry, Cursor, Page>>
 
   /**
    * Read one journal page through the addressed domain source.
@@ -143,7 +140,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   /**
-   * Establish follow before reading and publishing the initial page.
+   * Establish follow and publish the opening snapshot carried by its first frame.
    * @param request - initial tail-page request.
    * @returns after the first complete window is published.
    */
@@ -155,7 +152,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     try {
       const first = await this.takeNext(iterator)
       if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`)
-      await this.replaceGeneration(request, first.value, iterator, false)
+      this.replaceGeneration(first.value, false)
       this.opened = true
       this.done = this.consume(iterator)
     } catch (error) {
@@ -217,7 +214,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private async consume(
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
   ): Promise<void> {
     try {
       while (true) {
@@ -225,7 +222,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
         if (next.done) return
         const item = next.value
         if (item.generation !== this.generation) {
-          await this.replaceGeneration(this.repairPageRequest(), item, iterator, true)
+          this.replaceGeneration(item, true)
           continue
         }
         if (item.value.type === 'opened') {
@@ -238,35 +235,18 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     }
   }
 
-  private async replaceGeneration(
-    request: PageRequest,
-    initial: JournalStreamItem<Entry, Cursor>,
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+  private replaceGeneration(
+    initial: JournalStreamItem<Page, Entry, Cursor>,
     resumed: boolean,
-  ): Promise<void> {
-    let item = initial
-    let isResumed = resumed
-    while (true) {
-      const cursor = this.opening(item, isResumed)
-      this.setResumeCursor(cursor)
-      const superseded = await this.replaceThrough(
-        request,
-        cursor,
-        item.generation,
-        item.signal,
-        iterator,
-        [],
-      )
-      if (superseded === undefined) return
-      item = superseded
-      isResumed = true
-    }
+  ): void {
+    const opening = this.opening(initial, resumed)
+    this.replaceFromOpening(opening.page, opening.cursor)
   }
 
   private opening(
-    item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>,
+    item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor, Page>>,
     resumed: boolean,
-  ): Cursor {
+  ): { readonly cursor: Cursor; readonly page: Page } {
     if (item.value.type !== 'opened') {
       throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`)
     }
@@ -279,13 +259,30 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     }
     this.generation = item.generation
     item.accept()
-    return cursor
+    return { cursor, page: item.value.page }
+  }
+
+  /** Publish a generation's opening page without issuing a second Remote call. */
+  private replaceFromOpening(page: Page, cursor: Cursor): void {
+    this.assertPageThrough(page, cursor)
+    const entries = [...this.options.entries(page)]
+    this.assertPage(entries)
+    const first = entries[0]
+    this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
+    this.lastCursor = cursor
+    this.setResumeCursor(cursor)
+    this.options.publish({
+      type: 'replace',
+      page,
+      entries,
+      hasMore: this.options.hasMore(page),
+    })
   }
 
   private async acceptEntry(
     entry: Entry,
-    item: JournalStreamItem<Entry, Cursor>,
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    item: JournalStreamItem<Page, Entry, Cursor>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
   ): Promise<void> {
     const cursor = this.options.cursor(entry)
     const last = this.lastCursor as Cursor
@@ -301,7 +298,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
         [entry],
       )
       if (superseded !== undefined) {
-        await this.replaceGeneration(request, superseded, iterator, true)
+        this.replaceGeneration(superseded, true)
       }
       return
     }
@@ -316,9 +313,9 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     requiredCursor: Cursor,
     generation: number,
     signal: AbortSignal,
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
     queued: Entry[],
-  ): Promise<JournalStreamItem<Entry, Cursor> | undefined> {
+  ): Promise<JournalStreamItem<Page, Entry, Cursor> | undefined> {
     let read = await this.readPageWhileFollowing(
       request,
       requiredCursor,
@@ -351,6 +348,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       throw new Error(`${this.options.name} page did not reach its opening cursor`)
     }
     const first = entries[0]
+    /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */
     this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
     this.lastCursor = this.tailCursor(entries)
     this.setResumeCursor(this.lastCursor)
@@ -368,11 +366,11 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     through: Cursor,
     generation: number,
     signal: AbortSignal,
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
     queued: Entry[],
   ): Promise<
     | { readonly type: 'page'; readonly page: Page }
-    | { readonly type: 'superseded'; readonly item: JournalStreamItem<Entry, Cursor> }
+    | { readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor> }
   > {
     const page = this.readPage(request, through, signal).then(
       value => ({ type: 'page' as const, value }),
@@ -410,12 +408,12 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
 
   private async awaitReplacementGeneration(
     generation: number,
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
-    initial: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>>,
-  ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem<Entry, Cursor> }> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+    initial: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>>,
+  ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor> }> {
     let pending = initial
     while (true) {
-      let next: IteratorResult<JournalStreamItem<Entry, Cursor>>
+      let next: IteratorResult<JournalStreamItem<Page, Entry, Cursor>>
       try {
         next = await pending
       } finally {
@@ -461,15 +459,15 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private nextResult(
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
-  ): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> {
     this.pendingNext ??= iterator.next()
     return this.pendingNext
   }
 
   private async takeNext(
-    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
-  ): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> {
     const pending = this.nextResult(iterator)
     try {
       return await pending

+ 353 - 222
packages/api/gateway/tests/journal-stream.client.spec.ts

@@ -25,9 +25,12 @@ interface PageRequest {
   readonly limit?: number
 }
 
+type JournalFrame = RemoteJournalFrame<Entry, number, Page>
+type ScriptedFrame = JournalFrame
+
 interface Generation {
   readonly frames: readonly (
-    RemoteJournalFrame<Entry, number> | Promise<RemoteJournalFrame<Entry, number>>
+    ScriptedFrame | Promise<ScriptedFrame>
   )[]
   readonly terminal?: Error
   readonly hold?: boolean
@@ -67,7 +70,7 @@ class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageReques
     private readonly calls: string[],
     private readonly pageRequests: PageRequest[],
     private readonly pageCursors: number[],
-    private readonly followCursors: (number | undefined)[],
+    private readonly followRequests: PageRequest[],
     changes: RemoteJournalChange<Page, Entry>[],
     failed: (error: unknown) => void,
     factory: RemoteStreamFactory = STREAM_FACTORY,
@@ -87,11 +90,11 @@ class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageReques
 
   /** @inheritdoc */
   protected override async * follow(
-    after: number | undefined,
+    request: PageRequest,
     signal: AbortSignal,
-  ): AsyncIterable<RemoteJournalFrame<Entry, number>> {
+  ): AsyncIterable<JournalFrame> {
     this.calls.push('follow')
-    this.followCursors.push(after)
+    this.followRequests.push(request)
     const generation = this.generations.shift()
     if (generation === undefined) throw new Error('no scripted journal generation')
     for (const [index, frame] of generation.frames.entries()) {
@@ -138,12 +141,12 @@ function journalFixture(
   readonly calls: string[]
   readonly pageRequests: PageRequest[]
   readonly pageCursors: number[]
-  readonly followCursors: (number | undefined)[]
+  readonly followRequests: PageRequest[]
 } {
   const calls: string[] = []
   const pageRequests: PageRequest[] = []
   const pageCursors: number[] = []
-  const followCursors: (number | undefined)[] = []
+  const followRequests: PageRequest[] = []
   const changes: RemoteJournalChange<Page, Entry>[] = []
   const failed = vi.fn()
   const journal = new FixtureJournal(
@@ -152,24 +155,28 @@ function journalFixture(
     calls,
     pageRequests,
     pageCursors,
-    followCursors,
+    followRequests,
     changes,
     failed,
     factory,
   )
-  return { journal, changes, failed, calls, pageRequests, pageCursors, followCursors }
+  return { journal, changes, failed, calls, pageRequests, pageCursors, followRequests }
+}
+
+function opened(cursor: number, value: Page): JournalFrame {
+  return { type: 'opened', cursor, page: value }
 }
 
 function remoteItem(
   generation: number,
-  value: RemoteJournalFrame<Entry, number>,
+  value: ScriptedFrame,
   signal: AbortSignal,
-): RemoteStreamItem<RemoteJournalFrame<Entry, number>> {
+): RemoteStreamItem<JournalFrame> {
   return { generation, value, signal, accept: vi.fn() }
 }
 
 function controlledFactory(
-  next: () => Promise<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>,
+  next: () => Promise<IteratorResult<RemoteStreamItem<JournalFrame>>>,
 ): RemoteStreamFactory {
   const lifetime = new AbortController()
   return {
@@ -189,17 +196,17 @@ function controlledFactory(
 }
 
 describe('RemoteJournalStream', () => {
-  it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => {
+  it('opens from the follow snapshot, removes overlap, appends live entries, and prepends history', async () => {
     const fixture = journalFixture(
       [{
         frames: [
-          { type: 'opened', cursor: 3 },
+          opened(3, page('tail', [2, 3], true)),
           { type: 'entry', entry: { seq: 3 } },
           { type: 'entry', entry: { seq: 4 } },
         ],
         hold: true,
       }],
-      [page('tail', [2, 3], true), page('older', [0, 1])],
+      [page('older', [0, 1])],
     )
 
     await fixture.journal.open({ limit: 2 })
@@ -207,8 +214,8 @@ describe('RemoteJournalStream', () => {
     await fixture.journal.prepend({ before: 2, limit: 2 })
 
     expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page'])
-    expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }])
-    expect(fixture.pageCursors).toEqual([3, 4])
+    expect(fixture.pageRequests).toEqual([{ before: 2, limit: 2 }])
+    expect(fixture.pageCursors).toEqual([4])
     expect(fixture.changes).toEqual([
       { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true },
       { type: 'append', entry: { seq: 4 } },
@@ -220,8 +227,8 @@ describe('RemoteJournalStream', () => {
 
   it('exposes its shared cancellation signal', async () => {
     const fixture = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
-      [page('empty', [])],
+      [{ frames: [opened(-1, page('empty', []))], hold: true }],
+      [],
     )
 
     expect(fixture.journal.signal.aborted).toBe(false)
@@ -239,10 +246,10 @@ describe('RemoteJournalStream', () => {
     const finish = Promise.withResolvers<undefined>()
     const resumed = journalFixture(
       [
-        { frames: [{ type: 'opened', cursor: 0 }], waitAfterFrames: finish.promise },
+        { frames: [opened(0, page('initial', [0]))], waitAfterFrames: finish.promise },
         { frames: [] },
       ],
-      [page('initial', [0])],
+      [],
     )
     await resumed.journal.open({})
     finish.resolve(undefined)
@@ -255,8 +262,8 @@ describe('RemoteJournalStream', () => {
 
   it('prepends into an empty window and accepts its first live entry', async () => {
     const empty = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
-      [page('empty', []), page('older', [0]), page('oldest', [])],
+      [{ frames: [opened(-1, page('empty', []))], hold: true }],
+      [page('older', [0]), page('oldest', [])],
     )
     await empty.journal.open({})
     await empty.journal.prepend({})
@@ -269,10 +276,10 @@ describe('RemoteJournalStream', () => {
     })
     await empty.journal.dispose()
 
-    const live = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
+    const live = Promise.withResolvers<ScriptedFrame>()
     const followed = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: -1 }, live.promise], hold: true }],
-      [page('empty', [])],
+      [{ frames: [opened(-1, page('empty', [])), live.promise], hold: true }],
+      [],
     )
     await followed.journal.open({})
     live.resolve({ type: 'entry', entry: { seq: 0 } })
@@ -281,61 +288,27 @@ describe('RemoteJournalStream', () => {
     await followed.journal.dispose()
   })
 
-  it('publishes one sorted replacement from an exact page and live entries queued while it loads', async () => {
-    let resolvePage!: (value: Page) => void
-    const openingPage = new Promise<Page>((resolve) => { resolvePage = resolve })
-    const fixture = journalFixture(
-      [{
-        frames: [
-          { type: 'opened', cursor: 15 },
-          { type: 'entry', entry: { seq: 17 } },
-          { type: 'entry', entry: { seq: 16 } },
-        ],
-        hold: true,
-      }],
-      [openingPage],
-    )
-
-    const opening = fixture.journal.open({ limit: 6 })
-    await vi.waitFor(() => {
-      expect(fixture.calls.filter(call => call === 'page')).toHaveLength(1)
-    })
-    expect(fixture.changes).toEqual([])
-
-    resolvePage(page('opening', [10, 11, 12, 13, 14, 15]))
-    await opening
-
-    expect(fixture.changes).toEqual([{
-      type: 'replace',
-      page: page('opening', [10, 11, 12, 13, 14, 15]),
-      entries: entries(10, 11, 12, 13, 14, 15, 16, 17),
-      hasMore: false,
-    }])
-    expect(fixture.pageCursors).toEqual([15])
-    await fixture.journal.dispose()
-  })
-
   it('repairs a replacement generation through one tail page and drops replay overlap', async () => {
     const lost = new RemoteStreamCarrierError('carrier lost')
     const fixture = journalFixture(
       [
         {
           frames: [
-            { type: 'opened', cursor: 1 },
+            opened(1, page('initial', [0, 1])),
             { type: 'entry', entry: { seq: 2 } },
           ],
           terminal: lost,
         },
         {
           frames: [
-            { type: 'opened', cursor: 4 },
+            opened(4, page('replacement', [0, 1, 2, 3, 4])),
             { type: 'entry', entry: { seq: 3 } },
             { type: 'entry', entry: { seq: 4 } },
           ],
           hold: true,
         },
       ],
-      [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
+      [],
     )
 
     await fixture.journal.open({ limit: 5 })
@@ -343,10 +316,10 @@ describe('RemoteJournalStream', () => {
 
     expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
     expect(fixture.changes[2]).toMatchObject({
-      type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4),
+      type: 'replace', page: { marker: 'replacement' }, entries: entries(0, 1, 2, 3, 4),
     })
-    expect(fixture.followCursors).toEqual([undefined, 2])
-    expect(fixture.pageCursors).toEqual([1, 4])
+    expect(fixture.followRequests).toEqual([{ limit: 5 }, { limit: 5 }])
+    expect(fixture.pageCursors).toEqual([])
     expect(fixture.failed).not.toHaveBeenCalled()
     await fixture.journal.dispose()
   })
@@ -355,11 +328,14 @@ describe('RemoteJournalStream', () => {
     const fixture = journalFixture(
       [
         {
-          frames: [{ type: 'opened', cursor: 1 }],
+          frames: [
+            opened(1, page('initial', [0, 1])),
+            { type: 'entry', entry: { seq: 3 } },
+          ],
           terminal: new RemoteStreamCarrierError('carrier lost during page'),
         },
         {
-          frames: [{ type: 'opened', cursor: 2 }],
+          frames: [opened(3, page('replacement', [0, 1, 2, 3]))],
           hold: true,
         },
       ],
@@ -369,20 +345,28 @@ describe('RemoteJournalStream', () => {
           signal.addEventListener('abort', aborted, { once: true })
           if (signal.aborted) aborted()
         }),
-        page('replacement', [0, 1, 2]),
       ],
     )
 
     await fixture.journal.open({ limit: 3 })
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
 
-    expect(fixture.changes).toEqual([{
-      type: 'replace',
-      page: page('replacement', [0, 1, 2]),
-      entries: entries(0, 1, 2),
-      hasMore: false,
-    }])
-    expect(fixture.pageCursors).toEqual([1, 2])
-    expect(fixture.followCursors).toEqual([undefined, 1])
+    expect(fixture.changes).toEqual([
+      {
+        type: 'replace',
+        page: page('initial', [0, 1]),
+        entries: entries(0, 1),
+        hasMore: false,
+      },
+      {
+        type: 'replace',
+        page: page('replacement', [0, 1, 2, 3]),
+        entries: entries(0, 1, 2, 3),
+        hasMore: false,
+      },
+    ])
+    expect(fixture.pageCursors).toEqual([3])
+    expect(fixture.followRequests).toEqual([{ limit: 3 }, { limit: 3 }])
     expect(fixture.failed).not.toHaveBeenCalled()
     await fixture.journal.dispose()
   })
@@ -391,12 +375,12 @@ describe('RemoteJournalStream', () => {
     const fixture = journalFixture(
       [{
         frames: [
-          { type: 'opened', cursor: 1 },
+          opened(1, page('initial', [0, 1])),
           { type: 'entry', entry: { seq: 4 } },
         ],
         hold: true,
       }],
-      [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
+      [page('repair', [0, 1, 2, 3, 4])],
     )
 
     await fixture.journal.open({})
@@ -404,24 +388,22 @@ describe('RemoteJournalStream', () => {
 
     expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace'])
     expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } })
-    expect(fixture.pageCursors).toEqual([1, 4])
+    expect(fixture.pageCursors).toEqual([4])
     await fixture.journal.dispose()
   })
 
   it('replaces a superseded live-gap repair with the next generation', async () => {
-    const gap = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
+    const gap = Promise.withResolvers<ScriptedFrame>()
     const fixture = journalFixture(
       [
         {
-          frames: [{ type: 'opened', cursor: 1 }, gap.promise],
+          frames: [opened(1, page('initial', [0, 1])), gap.promise],
           terminal: new RemoteStreamCarrierError('generation lost'),
         },
-        { frames: [{ type: 'opened', cursor: 4 }], hold: true },
+        { frames: [opened(4, page('replacement', [0, 1, 2, 3, 4]))], hold: true },
       ],
       [
-        page('initial', [0, 1]),
         () => new Promise<Page>(() => {}),
-        page('replacement', [0, 1, 2, 3, 4]),
       ],
     )
 
@@ -435,103 +417,173 @@ describe('RemoteJournalStream', () => {
   })
 
   it('replaces a superseded second repair page with the next generation', async () => {
-    const live = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
-    const liveConsumed = Promise.withResolvers<undefined>()
-    const openingPage = Promise.withResolvers<Page>()
+    const firstLive = Promise.withResolvers<ScriptedFrame>()
+    const secondLive = Promise.withResolvers<ScriptedFrame>()
+    const secondConsumed = Promise.withResolvers<undefined>()
+    const firstRepair = Promise.withResolvers<Page>()
     const finish = Promise.withResolvers<undefined>()
     const fixture = journalFixture(
       [
         {
-          frames: [{ type: 'opened', cursor: 1 }, live.promise],
+          frames: [
+            opened(1, page('initial', [0, 1])),
+            firstLive.promise,
+            secondLive.promise,
+          ],
           waitAfterFrames: finish.promise,
           terminal: new RemoteStreamCarrierError('generation lost'),
-          afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) },
+          afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) },
         },
-        { frames: [{ type: 'opened', cursor: 4 }], hold: true },
+        { frames: [opened(5, page('replacement', [0, 1, 2, 3, 4, 5]))], hold: true },
       ],
       [
-        openingPage.promise,
-        () => new Promise<Page>(() => {}),
-        page('replacement', [0, 1, 2, 3, 4]),
+        firstRepair.promise,
+        signal => new Promise<Page>((_resolve, reject) => {
+          signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true })
+        }),
       ],
     )
 
-    const opening = fixture.journal.open({})
-    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) })
-    live.resolve({ type: 'entry', entry: { seq: 3 } })
-    await liveConsumed.promise
-    openingPage.resolve(page('opening', [0, 1]))
-    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) })
+    await fixture.journal.open({})
+    firstLive.resolve({ type: 'entry', entry: { seq: 3 } })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) })
+    secondLive.resolve({ type: 'entry', entry: { seq: 5 } })
+    await secondConsumed.promise
+    firstRepair.resolve(page('first-repair', [0, 1, 2, 3]))
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) })
     finish.resolve(undefined)
-    await opening
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
 
-    expect(fixture.pageCursors).toEqual([1, 3, 4])
-    expect(fixture.changes).toEqual([{
+    expect(fixture.pageCursors).toEqual([3, 5])
+    expect(fixture.changes).toEqual([
+      {
+        type: 'replace',
+        page: page('initial', [0, 1]),
+        entries: entries(0, 1),
+        hasMore: false,
+      },
+      {
+        type: 'replace',
+        page: page('replacement', [0, 1, 2, 3, 4, 5]),
+        entries: entries(0, 1, 2, 3, 4, 5),
+        hasMore: false,
+      },
+    ])
+    await fixture.journal.dispose()
+  })
+
+  it('rereads the tail when queued entries advance beyond the first repair page', async () => {
+    const firstLive = Promise.withResolvers<ScriptedFrame>()
+    const secondLive = Promise.withResolvers<ScriptedFrame>()
+    const secondConsumed = Promise.withResolvers<undefined>()
+    const firstRepair = Promise.withResolvers<Page>()
+    const fixture = journalFixture(
+      [{
+        frames: [
+          opened(1, page('initial', [0, 1])),
+          firstLive.promise,
+          secondLive.promise,
+        ],
+        hold: true,
+        afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) },
+      }],
+      [firstRepair.promise, page('repair', [0, 1, 2, 3, 4, 5])],
+    )
+
+    await fixture.journal.open({ limit: 4 })
+    firstLive.resolve({ type: 'entry', entry: { seq: 3 } })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) })
+    secondLive.resolve({ type: 'entry', entry: { seq: 5 } })
+    await secondConsumed.promise
+    firstRepair.resolve(page('first-repair', [0, 1, 2, 3]))
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+
+    expect(fixture.pageCursors).toEqual([3, 5])
+    expect(fixture.changes.at(-1)).toEqual({
       type: 'replace',
-      page: page('replacement', [0, 1, 2, 3, 4]),
-      entries: entries(0, 1, 2, 3, 4),
+      page: page('repair', [0, 1, 2, 3, 4, 5]),
+      entries: entries(0, 1, 2, 3, 4, 5),
       hasMore: false,
-    }])
+    })
     await fixture.journal.dispose()
   })
 
-  it('rereads the tail when queued entries advance beyond the opening page', async () => {
-    const live = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
-    const liveConsumed = Promise.withResolvers<undefined>()
-    const openingPage = Promise.withResolvers<Page>()
+  it('merges contiguous entries that arrive while a replacement page is loading', async () => {
+    const firstLive = Promise.withResolvers<ScriptedFrame>()
+    const secondLive = Promise.withResolvers<ScriptedFrame>()
+    const secondConsumed = Promise.withResolvers<undefined>()
+    const repair = Promise.withResolvers<Page>()
     const fixture = journalFixture(
       [{
-        frames: [{ type: 'opened', cursor: 1 }, live.promise],
+        frames: [
+          opened(1, page('initial', [0, 1])),
+          firstLive.promise,
+          secondLive.promise,
+        ],
         hold: true,
-        afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) },
+        afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) },
       }],
-      [openingPage.promise, page('repair', [0, 1, 2, 3])],
+      [repair.promise],
     )
 
-    const opening = fixture.journal.open({ limit: 4 })
-    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) })
-    live.resolve({ type: 'entry', entry: { seq: 3 } })
-    await liveConsumed.promise
-    openingPage.resolve(page('opening', [0, 1]))
-    await opening
-
-    expect(fixture.pageCursors).toEqual([1, 3])
-    expect(fixture.changes).toEqual([{
-      type: 'replace', page: page('repair', [0, 1, 2, 3]), entries: entries(0, 1, 2, 3), hasMore: false,
-    }])
+    await fixture.journal.open({})
+    firstLive.resolve({ type: 'entry', entry: { seq: 3 } })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) })
+    secondLive.resolve({ type: 'entry', entry: { seq: 4 } })
+    await secondConsumed.promise
+    repair.resolve(page('repair', [0, 1, 2, 3]))
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+
+    expect(fixture.changes.at(-1)).toEqual({
+      type: 'replace',
+      page: page('repair', [0, 1, 2, 3]),
+      entries: entries(0, 1, 2, 3, 4),
+      hasMore: false,
+    })
     await fixture.journal.dispose()
   })
 
   it('rejects when queued entries advance beyond the second repair page', async () => {
-    const firstLive = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
-    const secondLive = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
-    const firstConsumed = Promise.withResolvers<undefined>()
+    const firstLive = Promise.withResolvers<ScriptedFrame>()
+    const secondLive = Promise.withResolvers<ScriptedFrame>()
+    const thirdLive = Promise.withResolvers<ScriptedFrame>()
     const secondConsumed = Promise.withResolvers<undefined>()
-    const openingPage = Promise.withResolvers<Page>()
-    const repairPage = Promise.withResolvers<Page>()
+    const thirdConsumed = Promise.withResolvers<undefined>()
+    const firstRepair = Promise.withResolvers<Page>()
+    const secondRepair = Promise.withResolvers<Page>()
     const fixture = journalFixture(
       [{
-        frames: [{ type: 'opened', cursor: 1 }, firstLive.promise, secondLive.promise],
+        frames: [
+          opened(1, page('initial', [0, 1])),
+          firstLive.promise,
+          secondLive.promise,
+          thirdLive.promise,
+        ],
         hold: true,
         afterFrame: (index) => {
-          if (index === 1) firstConsumed.resolve(undefined)
           if (index === 2) secondConsumed.resolve(undefined)
+          if (index === 3) thirdConsumed.resolve(undefined)
         },
       }],
-      [openingPage.promise, repairPage.promise],
+      [firstRepair.promise, secondRepair.promise],
     )
 
-    const opening = fixture.journal.open({})
-    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) })
+    await fixture.journal.open({})
     firstLive.resolve({ type: 'entry', entry: { seq: 3 } })
-    await firstConsumed.promise
-    openingPage.resolve(page('opening', [0, 1]))
-    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) })
     secondLive.resolve({ type: 'entry', entry: { seq: 5 } })
     await secondConsumed.promise
-    repairPage.resolve(page('repair', [0, 1, 2, 3]))
+    firstRepair.resolve(page('first-repair', [0, 1, 2, 3]))
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) })
+    thirdLive.resolve({ type: 'entry', entry: { seq: 7 } })
+    await thirdConsumed.promise
+    secondRepair.resolve(page('second-repair', [0, 1, 2, 3, 4, 5]))
 
-    await expect(opening).rejects.toThrow('page did not reach its opening cursor')
+    await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
+    expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({
+      message: 'fixture journal page did not reach its opening cursor',
+    })
+    await fixture.journal.dispose()
   })
 
   it('reports a resumed generation that emits an entry before its cursor', async () => {
@@ -539,13 +591,13 @@ describe('RemoteJournalStream', () => {
     const fixture = journalFixture(
       [
         {
-          frames: [{ type: 'opened', cursor: 0 }],
+          frames: [opened(0, page('initial', [0]))],
           waitAfterFrames: finish.promise,
           terminal: new RemoteStreamCarrierError('lost'),
         },
         { frames: [{ type: 'entry', entry: { seq: 1 } }] },
       ],
-      [page('initial', [0])],
+      [],
     )
 
     await fixture.journal.open({})
@@ -558,14 +610,14 @@ describe('RemoteJournalStream', () => {
   })
 
   it('reports a duplicate opening cursor after the initial page is published', async () => {
-    const duplicate = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
+    const duplicate = Promise.withResolvers<ScriptedFrame>()
     const fixture = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise], hold: true }],
-      [page('initial', [0])],
+      [{ frames: [opened(0, page('initial', [0])), duplicate.promise], hold: true }],
+      [],
     )
 
     await fixture.journal.open({})
-    duplicate.resolve({ type: 'opened', cursor: 0 })
+    duplicate.resolve(opened(0, page('duplicate', [0])))
     await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
     expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({
       message: 'fixture journal emitted more than one opening cursor',
@@ -573,47 +625,16 @@ describe('RemoteJournalStream', () => {
     await fixture.journal.dispose()
   })
 
-  it('propagates follow failures and duplicate cursors while an opening page is pending', async () => {
-    const pendingPage = new Promise<Page>(() => {})
+  it('reports a follow failure after publishing its opening snapshot', async () => {
     const failedFollow = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 0 }], terminal: new Error('follow failed') }],
-      [pendingPage],
-    )
-    await expect(failedFollow.journal.open({})).rejects.toThrow('follow failed')
-
-    const duplicate = Promise.withResolvers<RemoteJournalFrame<Entry, number>>()
-    const duplicatePage = new Promise<Page>(() => {})
-    const duplicateOpening = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise] }],
-      [duplicatePage],
-    )
-    const opening = duplicateOpening.journal.open({})
-    await vi.waitFor(() => { expect(duplicateOpening.pageCursors).toEqual([0]) })
-    duplicate.resolve({ type: 'opened', cursor: 0 })
-    await expect(opening).rejects.toThrow('more than one opening cursor')
-  })
-
-  it('rejects an iterator that ends while its opening page is pending', async () => {
-    const generation = new AbortController()
-    const results = [
-      Promise.resolve<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>({
-        done: false,
-        value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal),
-      }),
-      Promise.resolve<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>({
-        done: true,
-        value: undefined,
-      }),
-    ]
-    const fixture = journalFixture(
+      [{ frames: [opened(0, page('initial', [0]))], terminal: new Error('follow failed') }],
       [],
-      [new Promise<Page>(() => {})],
-      controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })),
-    )
-
-    await expect(fixture.journal.open({})).rejects.toThrow(
-      'ended while reading its replacement page',
     )
+    await failedFollow.journal.open({})
+    await vi.waitFor(() => { expect(failedFollow.failed).toHaveBeenCalledOnce() })
+    expect(failedFollow.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'follow failed' })
+    expect(failedFollow.changes).toHaveLength(1)
+    await failedFollow.journal.dispose()
   })
 
   it('rejects an iterator that ends before its opening cursor', async () => {
@@ -627,17 +648,17 @@ describe('RemoteJournalStream', () => {
 
   it('suppresses a consumer failure after disposal begins', async () => {
     const generation = new AbortController()
-    const next = Promise.withResolvers<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>()
+    const next = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
     const results = [
-      Promise.resolve<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>({
+      Promise.resolve<IteratorResult<RemoteStreamItem<JournalFrame>>>({
         done: false,
-        value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal),
+        value: remoteItem(1, opened(0, page('initial', [0])), generation.signal),
       }),
       next.promise,
     ]
     const fixture = journalFixture(
       [],
-      [page('initial', [0])],
+      [],
       controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })),
     )
 
@@ -645,7 +666,7 @@ describe('RemoteJournalStream', () => {
     const closing = fixture.journal.dispose()
     next.resolve({
       done: false,
-      value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal),
+      value: remoteItem(1, opened(0, page('duplicate', [0])), generation.signal),
     })
     await closing
     expect(fixture.failed).not.toHaveBeenCalled()
@@ -658,17 +679,17 @@ describe('RemoteJournalStream', () => {
       final: undefined,
       message: 'more than one opening cursor',
     },
-  ])('rejects when an aborted page generation $name', async ({ final, message }) => {
+  ])('reports when an aborted repair generation $name', async ({ final, message }) => {
     const generation = new AbortController()
-    const pending = Promise.withResolvers<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>()
-    const nextPending = Promise.withResolvers<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>()
+    const gap = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const replacement = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
     const results = [
-      Promise.resolve<IteratorResult<RemoteStreamItem<RemoteJournalFrame<Entry, number>>>>({
+      Promise.resolve<IteratorResult<RemoteStreamItem<JournalFrame>>>({
         done: false,
-        value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal),
+        value: remoteItem(1, opened(0, page('initial', [0])), generation.signal),
       }),
-      pending.promise,
-      nextPending.promise,
+      gap.promise,
+      replacement.promise,
     ]
     const fixture = journalFixture(
       [],
@@ -678,47 +699,154 @@ describe('RemoteJournalStream', () => {
       controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })),
     )
 
-    const opening = fixture.journal.open({})
-    await vi.waitFor(() => { expect(results).toHaveLength(1) })
+    await fixture.journal.open({})
+    gap.resolve({
+      done: false,
+      value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal),
+    })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) })
     generation.abort()
     if (final === undefined) {
-      pending.resolve({
-        done: false,
-        value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal),
-      })
-      await vi.waitFor(() => { expect(results).toHaveLength(0) })
-      nextPending.resolve({
+      replacement.resolve({
         done: false,
-        value: remoteItem(1, { type: 'opened', cursor: 1 }, generation.signal),
+        value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), generation.signal),
       })
     } else {
-      pending.resolve(final)
+      replacement.resolve(final)
     }
-    await expect(opening).rejects.toThrow(message)
+    await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
+    const failure: unknown = fixture.failed.mock.calls[0]?.[0]
+    expect(failure).toBeInstanceOf(Error)
+    if (!(failure instanceof Error)) throw new Error('journal failure was not an Error')
+    expect(failure.message).toContain(message)
+    await fixture.journal.dispose()
+  })
+
+  it('discards old-generation entries while waiting for the replacement opening', async () => {
+    const generation = new AbortController()
+    const gap = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const stale = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const replacement = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const results = [
+      Promise.resolve<IteratorResult<RemoteStreamItem<JournalFrame>>>({
+        done: false,
+        value: remoteItem(1, opened(0, page('initial', [0])), generation.signal),
+      }),
+      gap.promise,
+      stale.promise,
+      replacement.promise,
+    ]
+    const fixture = journalFixture(
+      [],
+      [signal => new Promise<Page>((_resolve, reject) => {
+        signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true })
+      })],
+      controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })),
+    )
+
+    await fixture.journal.open({})
+    gap.resolve({
+      done: false,
+      value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal),
+    })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) })
+    generation.abort()
+    stale.resolve({
+      done: false,
+      value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal),
+    })
+    replacement.resolve({
+      done: false,
+      value: remoteItem(2, opened(2, page('replacement', [0, 1, 2])), new AbortController().signal),
+    })
+
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+    expect(fixture.changes.at(-1)).toMatchObject({ page: { marker: 'replacement' } })
+    await fixture.journal.dispose()
+  })
+
+  it.each([
+    {
+      name: 'rejects',
+      settle: (
+        _resolve: (value: IteratorResult<RemoteStreamItem<JournalFrame>>) => void,
+        reject: (reason?: unknown) => void,
+      ) => { reject(new Error('replacement follow failed')) },
+      message: 'replacement follow failed',
+    },
+    {
+      name: 'ends',
+      settle: (resolve: (value: IteratorResult<RemoteStreamItem<JournalFrame>>) => void) => {
+        resolve({ done: true, value: undefined })
+      },
+      message: 'ended while reading its replacement page',
+    },
+    {
+      name: 'opens twice',
+      settle: (resolve: (value: IteratorResult<RemoteStreamItem<JournalFrame>>) => void) => {
+        resolve({
+          done: false,
+          value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), new AbortController().signal),
+        })
+      },
+      message: 'more than one opening cursor',
+    },
+  ])('reports when a follow $name during live-gap repair', async ({ settle, message }) => {
+    const generation = new AbortController()
+    const gap = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const next = Promise.withResolvers<IteratorResult<RemoteStreamItem<JournalFrame>>>()
+    const results = [
+      Promise.resolve<IteratorResult<RemoteStreamItem<JournalFrame>>>({
+        done: false,
+        value: remoteItem(1, opened(0, page('initial', [0])), generation.signal),
+      }),
+      gap.promise,
+      next.promise,
+    ]
+    const fixture = journalFixture(
+      [],
+      [() => new Promise<Page>(() => {})],
+      controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })),
+    )
+
+    await fixture.journal.open({})
+    gap.resolve({
+      done: false,
+      value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal),
+    })
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) })
+    settle(next.resolve, next.reject)
+
+    await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
+    const failure: unknown = fixture.failed.mock.calls[0]?.[0]
+    expect(failure).toBeInstanceOf(Error)
+    if (!(failure instanceof Error)) throw new Error('journal failure was not an Error')
+    expect(failure.message).toContain(message)
+    await fixture.journal.dispose()
   })
 
   it('rejects malformed opening and page sequences', async () => {
     const beforeOpening = journalFixture(
       [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }],
-      [page('unused', [])],
+      [],
     )
     await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor')
 
     const discontinuousPage = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
-      [page('bad', [0, 2, 3])],
+      [{ frames: [opened(3, page('bad', [0, 2, 3]))], hold: true }],
+      [],
     )
     await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries')
 
     const shortPage = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
-      [page('short', [0, 1])],
+      [{ frames: [opened(3, page('short', [0, 1]))], hold: true }],
+      [],
     )
     await expect(shortPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor')
 
     const longPage = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 1 }], hold: true }],
-      [page('long', [0, 1, 2])],
+      [{ frames: [opened(1, page('long', [0, 1, 2]))], hold: true }],
+      [],
     )
     await expect(longPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor')
   })
@@ -726,9 +854,12 @@ describe('RemoteJournalStream', () => {
   it('reports duplicate and regressed generation cursors as terminal failures', async () => {
     const duplicate = journalFixture(
       [{
-        frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }],
+        frames: [
+          opened(1, page('initial', [0, 1])),
+          opened(1, page('duplicate', [0, 1])),
+        ],
       }],
-      [page('initial', [0, 1])],
+      [],
     )
     await duplicate.journal.open({})
     await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() })
@@ -740,12 +871,12 @@ describe('RemoteJournalStream', () => {
     const regressed = journalFixture(
       [
         {
-          frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }],
+          frames: [opened(1, page('initial', [0, 1])), { type: 'entry', entry: { seq: 2 } }],
           terminal: new RemoteStreamCarrierError('lost'),
         },
-        { frames: [{ type: 'opened', cursor: 1 }] },
+        { frames: [opened(1, page('regressed', [0, 1]))] },
       ],
-      [page('initial', [0, 1])],
+      [],
     )
     await regressed.journal.open({})
     await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() })
@@ -757,8 +888,8 @@ describe('RemoteJournalStream', () => {
 
   it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => {
     const fixture = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: 4 }], hold: true }],
-      [page('initial', [3, 4], true), page('older', [0, 1], true)],
+      [{ frames: [opened(4, page('initial', [3, 4], true))], hold: true }],
+      [page('older', [0, 1], true)],
     )
     await fixture.journal.open({})
 
@@ -771,8 +902,8 @@ describe('RemoteJournalStream', () => {
 
   it('guards lifecycle operations before and after open', async () => {
     const fixture = journalFixture(
-      [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
-      [page('empty', [])],
+      [{ frames: [opened(-1, page('empty', []))], hold: true }],
+      [],
     )
 
     await expect(fixture.journal.prepend({})).rejects.toThrow('is not open')

+ 1 - 1
packages/api/remotes/src/client/index.ts

@@ -49,7 +49,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
 export type {
   ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
   CredentialView, DirectoryListing, DiscoveredModelView, IApiClient,
-  MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
+  MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
   RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
   SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
   SubagentAddress, SubagentCatalog,

+ 0 - 1
packages/api/session-controller/package.json

@@ -104,7 +104,6 @@
   "peerDependenciesMeta": {
     "@deepseek-ai/dsh-jobs": { "optional": true },
     "@deepseek-ai/dsh-session-persistence": { "optional": true },
-    "@deepseek-ai/dsh-session-projection": { "optional": true },
     "@deepseek-ai/dsh-session-projection-cache": { "optional": true }
   },
   "devDependencies": {

+ 158 - 45
packages/api/session-controller/src/agent.ts

@@ -7,12 +7,13 @@ import type {
   Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
 } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-default-model'
-import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-agent-presets'
+import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-session-persistence'
+import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-typert-registry'
-import type { SessionError } from './types.ts'
+import type { ModelSelection, SessionError } from './types.ts'
 
 /** Cold Session identity absent from persistence. */
 export class ApiSessionNotFound extends Error {}
@@ -66,7 +67,10 @@ export type ApiSessionAgentResult =
   | { readonly agent: Agent }
   | { readonly error: ApiSessionAgentError }
 
-type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
+type InstalledSelection = ModelSelectionRef & {
+  current: AgentModelSelection
+  consume(provider: string, model: string, reasoningEffort: string | undefined): boolean
+}
 
 /**
  * Test whether generic Session routing must leave an identity to subagent routing.
@@ -112,19 +116,22 @@ export async function inspectApiSession(
   sessionId: SessionId,
   signal?: AbortSignal,
 ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
-  const persistence = ctx.get('sessionPersistence')
-  if (persistence === undefined) {
-    throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
-  }
-  const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
-  if (meta === undefined || meta.cwd === undefined) {
-    throw new ApiSessionNotFound(`session "${sessionId}" not found`)
-  }
-  const inspected = await persistence.inspect(sessionId, signal)
-  if (inspected.meta.cwd === undefined) {
-    throw new ApiSessionNotFound(`session "${sessionId}" not found`)
+  try {
+    using observation = await ctx.sessionQuery.observeSession(sessionId, {
+      ...(signal === undefined ? {} : { signal }),
+      projectionMode: 'none',
+    })
+    if (observation.header.cwd === undefined) {
+      throw new ApiSessionNotFound(`session "${sessionId}" not found`)
+    }
+    return { meta: observation.header, events: [...observation.events] }
+  } catch (error: unknown) {
+    if (error instanceof SessionQueryError
+      && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
+      throw new ApiSessionNotFound(`session "${sessionId}" not found`)
+    }
+    throw error
   }
-  return { meta: inspected.meta, events: [...inspected.events] }
 }
 
 /** Owns every operation that may create, resume, or configure a Web Agent. */
@@ -159,6 +166,22 @@ export class ApiSessionAgentController {
    * @returns the live Agent or a stable Session-domain failure.
    */
   async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
+    return this.resolve(sessionId)
+  }
+
+  /**
+   * Resolve one ordinary Session from an already-retained exact observation.
+   * @param observation - Host-owned observation whose preparation stays pinned through setup.
+   * @returns the live Agent or a stable Session-domain failure.
+   */
+  async resolveObservedAgent(observation: SessionObservation): Promise<ApiSessionAgentResult> {
+    return this.resolve(observation.header.id, observation)
+  }
+
+  private async resolve(
+    sessionId: SessionId,
+    observation?: SessionObservation,
+  ): Promise<ApiSessionAgentResult> {
     const live = this.liveAgent(sessionId)
     if (live !== undefined) return live
     const attached = this.ctx.sessions.get(sessionId)
@@ -168,7 +191,7 @@ export class ApiSessionAgentController {
 
     let resume = this.resumes.get(sessionId)
     if (resume === undefined) {
-      resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
+      resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) })
       this.resumes.set(sessionId, resume)
     }
     try {
@@ -240,7 +263,9 @@ export class ApiSessionAgentController {
     if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
       throw new ApiSessionSubagentOwnership(sessionId)
     }
-    this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
+    if (presetId !== undefined) {
+      this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
+    }
     if (agent.session.header.cwd !== cwd) {
       throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
     }
@@ -255,7 +280,13 @@ export class ApiSessionAgentController {
   selectionFor(agent: Agent): InstalledSelection {
     const installed = this.selections.get(agent)
     if (installed !== undefined) return installed
-    let picked: AgentModelSelection | undefined
+    const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
+    if (projectionState === undefined) {
+      throw new Error('api-session: required modelSelection projection is not registered')
+    }
+    let picked = projectionState.pending === null
+      ? undefined
+      : agentModelSelection(projectionState.pending)
     const defaultModel = this.ctx.agentDefaultModel
     const selection: InstalledSelection = {
       get current(): AgentModelSelection {
@@ -271,6 +302,13 @@ export class ApiSessionAgentController {
       set current(next: AgentModelSelection) {
         picked = next
       },
+      consume(provider: string, model: string, reasoningEffort: string | undefined): boolean {
+        if (picked?.provider !== provider
+          || picked.model !== model
+          || picked.reasoningEffort !== reasoningEffort) return false
+        picked = undefined
+        return true
+      },
       assembled: undefined,
     }
     installModelSelection(agent.ctx, selection)
@@ -278,6 +316,42 @@ export class ApiSessionAgentController {
     return selection
   }
 
+  /**
+   * Commit and cache one validated selection for the next prompt assembly.
+   * @param agent - live Agent that owns the selection.
+   * @param selection - validated selection to record and apply.
+   */
+  selectForNextRequest(agent: Agent, selection: AgentModelSelection): void {
+    agent.session.append('model/selection', selection)
+    this.selectionFor(agent).current = selection
+  }
+
+  /**
+   * Let a matching durable request header retire the execution cache.
+   * @param agent - live Agent whose request was recorded.
+   * @param provider - provider route used by the request.
+   * @param model - provider-owned model used by the request.
+   * @param reasoningEffort - adapter-owned effort used by the request.
+   * @returns whether the pending selection was consumed.
+   */
+  consumeSelection(
+    agent: Agent,
+    provider: string,
+    model: string,
+    reasoningEffort: string | undefined,
+  ): boolean {
+    return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false
+  }
+
+  /**
+   * Read the current Agent preset from the Session projection.
+   * @param session - live Session whose projection state is available.
+   * @returns the current preset, or undefined when the capability is absent.
+   */
+  presetForSession(session: Session): string | undefined {
+    return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined
+  }
+
   /**
    * Serialize image admission and model selection for one Agent.
    * @param agent - live Agent that owns the serialization chain.
@@ -319,15 +393,31 @@ export class ApiSessionAgentController {
       : { agent }
   }
 
-  private async resume(sessionId: SessionId): Promise<Agent> {
-    const inspected = await inspectApiSession(this.ctx, sessionId)
-    if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
+  private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise<Agent> {
+    if (supplied !== undefined) return this.resumeObserved(sessionId, supplied)
+    try {
+      using observation = await this.ctx.sessionQuery.observeSession(sessionId)
+      return await this.resumeObserved(sessionId, observation)
+    } catch (error: unknown) {
+      if (error instanceof SessionQueryError
+        && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
+        throw new ApiSessionNotFound(`session "${sessionId}" not found`)
+      }
+      throw error
+    }
+  }
+
+  private async resumeObserved(
+    sessionId: SessionId,
+    observation: SessionObservation,
+  ): Promise<Agent> {
+    if (observation.header.id !== sessionId || observation.header.cwd === undefined) {
+      throw new ApiSessionNotFound(`session "${sessionId}" not found`)
+    }
+    if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
       throw new ApiSessionSubagentOwnership(sessionId)
     }
-    const composition = await this.composeAgent(resolveSessionPreset({
-      header: inspected.meta,
-      events: inspected.events,
-    }))
+    const composition = await this.composeAgent(this.presetForObservation(observation))
     const published = this.ctx.sessions.get(sessionId)
     const live = this.ctx.agents.get(sessionId)
     if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
@@ -353,26 +443,27 @@ export class ApiSessionAgentController {
     }
     if (live !== undefined) return live
 
-    const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
-    const stored = persistence === undefined
-      ? undefined
-      : (await persistence.list()).find(header => header.id === sessionId)
-    if (persistence !== undefined && stored !== undefined) {
-      const inspected = await persistence.inspect(sessionId)
-      if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
-        throw new ApiSessionSubagentOwnership(sessionId)
-      }
-      if (inspected.meta.cwd !== cwd) {
-        throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
+    if (checkPersistedIdentity) {
+      try {
+        using observation = await this.ctx.sessionQuery.observeSession(sessionId)
+        if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
+          throw new ApiSessionSubagentOwnership(sessionId)
+        }
+        if (observation.header.cwd !== cwd) {
+          throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
+        }
+        const storedPreset = this.presetForObservation(observation)
+        this.assertPresetUnchanged(sessionId, presetId, storedPreset)
+        const composition = await this.composeAgent(storedPreset)
+        return (await this.ctx.agents.resume({
+          resumeSessionId: sessionId,
+          agentOptions: this.agentOptions(),
+          setup: composition.setup,
+        })).agent
+      } catch (error: unknown) {
+        if (!(error instanceof SessionQueryError)
+          || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
       }
-      const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
-      this.assertPresetUnchanged(sessionId, presetId, storedPreset)
-      const composition = await this.composeAgent(storedPreset)
-      return (await this.ctx.agents.resume({
-        resumeSessionId: sessionId,
-        agentOptions: this.agentOptions(),
-        setup: composition.setup,
-      })).agent
     }
 
     try {
@@ -403,6 +494,18 @@ export class ApiSessionAgentController {
     this.selectionFor(agent)
   }
 
+  /**
+   * Read the current Agent preset from an all-projections observation.
+   * @param observation - exact Session observation carrying its projection snapshot.
+   * @returns the current preset, or undefined when the capability is absent.
+   */
+  presetForObservation(observation: SessionObservation): string | undefined {
+    if (observation.projections === undefined) {
+      throw new Error('api-session: Agent activation requires a projected Session observation')
+    }
+    return observation.projections.values.agentPreset ?? undefined
+  }
+
   private assertPresetUnchanged(
     sessionId: SessionId,
     requested: string | undefined,
@@ -412,3 +515,13 @@ export class ApiSessionAgentController {
     throw new ApiSessionPresetConflict(sessionId, requested, existing)
   }
 }
+
+function agentModelSelection(selection: ModelSelection): AgentModelSelection {
+  return {
+    provider: selection.provider,
+    model: selection.model,
+    ...(selection.reasoningEffort === undefined
+      ? {}
+      : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
+  }
+}

+ 11 - 7
packages/api/session-controller/src/catalog.ts

@@ -2,21 +2,23 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {
-  ModelCatalogFailure,
-  ModelProviderGroup,
+  ModelCatalog,
   ModelReasoning,
+  ModelSelection,
 } from './types.ts'
 
 /**
  * Build the browser model catalog without requiring a Session.
  * @param ctx - Host context carrying the live LLM registry.
+ * @param defaultSelection - deployment default used before a Session selects a model.
  * @returns successful non-empty provider groups and isolated provider failures.
  */
-export async function buildModelCatalog(ctx: Context): Promise<{
-  readonly groups: ModelProviderGroup[]
-  readonly failures: ModelCatalogFailure[]
-}> {
-  const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
+export async function buildModelCatalog(
+  ctx: Context,
+  defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(),
+): Promise<ModelCatalog> {
+  const providers = ctx.llm.listProviders()
+  const catalog = await Promise.all(providers.map(async (provider) => {
     try {
       const models = await ctx.llm.listModels(provider.id)
       const entries = await Promise.all(models.map(async (model) => {
@@ -56,6 +58,8 @@ export async function buildModelCatalog(ctx: Context): Promise<{
     }
   }))
   return {
+    default: { ...defaultSelection },
+    routableProviders: providers.map(provider => provider.id),
     groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
       .filter(group => group.models.length > 0),
     failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),

+ 0 - 8
packages/api/session-controller/src/client/contract/sessions.ts

@@ -66,14 +66,6 @@ export interface ISessions {
    */
   refreshSubagents(parentSessionId: SessionId): Promise<void>
 
-  /**
-   * Record the composition one session now runs. The agent-preset seat calls
-   * this after a successful blank-session switch, so the header label moves
-   * with the composition instead of waiting for the next full list refresh.
-   * @param sessionId - the switched session.
-   * @param agentPreset - the preset id the host confirmed.
-   */
-  noteAgentPreset(sessionId: SessionId, agentPreset: string): void
   /** Clear the current selection into the no-session view state. */
   clear(): void
   /**

+ 5 - 1
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -29,7 +29,11 @@ export interface SessionSnapshot {
   readonly sessionId: SessionId
   readonly queue: readonly QueuedMessage[]
   readonly running: boolean
-  readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null
+  readonly subagent: {
+    readonly address: SubagentAddress
+    /** Absent until the direct-parent catalog resolves. */
+    readonly parentAvailable?: boolean
+  } | null
   readonly removed: boolean
   readonly openState: OpenState
   readonly openError: ClientFailure | null

+ 0 - 2
packages/api/session-controller/src/client/sessions/lineage.ts

@@ -25,8 +25,6 @@ export interface SessionListEntry {
   /** Coarse durable origin for navigation filtering; not a continuation capability. */
   origin?: 'subagent'
   cwd?: string
-  /** Agent preset the session's agent was composed from (summary passthrough). */
-  agentPreset?: string
   /** Current host-computed projection values for list consumers. */
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
   /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */

+ 29 - 29
packages/api/session-controller/src/client/sessions/manager.ts

@@ -61,11 +61,19 @@ export interface SessionListSnapshot {
 }
 
 /** One parent-addressed durable catalog projected through the sessions snapshot. */
-export interface SubagentCatalogSnapshot extends SubagentCatalog {
+export type SubagentCatalogSnapshot = Omit<SubagentCatalog, 'parentAvailable'> & {
+  /** Absent until the first successful catalog read. */
+  readonly parentAvailable?: boolean
   state: 'loading' | 'ready' | 'error'
   error: ClientFailure | null
 }
 
+function catalogAvailability(parentAvailable: boolean | undefined): {
+  readonly parentAvailable?: boolean
+} {
+  return parentAvailable === undefined ? {} : { parentAvailable }
+}
+
 interface CatalogInflight {
   readonly promise: Promise<void>
   readonly expandableRows: Set<SessionId>
@@ -166,8 +174,8 @@ export class SessionManager {
     this.sessions.get(sessionId)?.configureSubagent(
       address,
       address === undefined
-        ? false
-        : this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
+        ? undefined
+        : this.catalogs.get(address.parentSessionId)?.parentAvailable,
     )
     this.selected = sessionId
     // Looking at the session consumes its completion reminder (dot clears).
@@ -187,7 +195,7 @@ export class SessionManager {
       throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
     }
     this.addresses.set(address.childSessionId, address)
-    this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
+    this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable)
     this.selected = address.childSessionId
     this.completedNotifications.delete(address.childSessionId)
     void this.refreshSubagents(address.childSessionId)
@@ -310,10 +318,13 @@ export class SessionManager {
 
   private createSession(sessionId: SessionId): Session {
     const address = this.addresses.get(sessionId)
+    const parentAvailable = address === undefined
+      ? undefined
+      : this.catalogs.get(address.parentSessionId)?.parentAvailable
     return new Session(sessionId, this.api, this.remote, {
       ...(address === undefined ? {} : {
         address,
-        parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
+        ...catalogAvailability(parentAvailable),
       }),
       // The sender's local first-send flip mirrors into the list row so the
       // session surfaces (lists filter on blank) before any host frame lands.
@@ -349,7 +360,9 @@ export class SessionManager {
     const activityRows = new Map<SessionId, 'running' | 'inactive'>()
     this.catalogs.set(parentSessionId, {
       entries: previous?.entries ?? [],
-      parentAvailable: previous?.parentAvailable ?? false,
+      ...(previous?.parentAvailable === undefined
+        ? {}
+        : { parentAvailable: previous.parentAvailable }),
       state: 'loading',
       error: null,
     })
@@ -376,8 +389,10 @@ export class SessionManager {
             entries: this.withCatalogMutations(
               previous?.entries ?? [], expandableRows, activityRows,
             ),
-            parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
-              ?? previous?.parentAvailable ?? false,
+            ...catalogAvailability(
+              this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
+                ?? previous?.parentAvailable,
+            ),
             state: 'error',
             error: result.error,
           })
@@ -388,8 +403,10 @@ export class SessionManager {
           entries: this.withCatalogMutations(
             previous?.entries ?? [], expandableRows, activityRows,
           ),
-          parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
-            ?? previous?.parentAvailable ?? false,
+          ...catalogAvailability(
+            this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
+              ?? previous?.parentAvailable,
+          ),
           state: 'error',
           error: folded.ok ? null : folded.error,
         })
@@ -555,7 +572,6 @@ export class SessionManager {
         this.recordMutation({ kind: 'upsert', summary: {
           sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
           ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
-          ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
         } })
       } else {
         const publishedSessionId = workspaceAttachSessionId(result.error)
@@ -621,17 +637,6 @@ export class SessionManager {
     this.recordMutation({ kind: 'upsert', summary })
   }
 
-  /**
-   * Record a host-confirmed composition switch (see ISessions.noteAgentPreset).
-   * @param sessionId - the switched session.
-   * @param agentPreset - the preset id the host confirmed.
-   */
-  noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
-    this.recordMutation({ kind: 'upsert', summary: {
-      sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
-    } })
-  }
-
   /** Apply immediately and retain for replay when a list response is in flight. */
   private recordMutation(mutation: SessionListMutation): void {
     this.listMutations?.push(mutation)
@@ -932,7 +937,7 @@ export class SessionManager {
       const prev = this.entryCache.get(entry.sessionId)
       if (
         prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
-        && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
+        && prev.blank === entry.blank
         && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
         && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
         && prev.projectionValues === entry.projectionValues
@@ -980,15 +985,10 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
           ? { parentSessionId: mutation.summary.parentSessionId } : {}),
         ...(existing.origin === undefined && mutation.summary.origin !== undefined
           ? { origin: mutation.summary.origin } : {}),
-        // Newest wins, not fill-only: a blank-session preset switch replaces
-        // the creation-time value, and every producer of this field (the
-        // create echo, the select echo, a list row) reports the CURRENT one.
-        ...(mutation.summary.agentPreset !== undefined
-          ? { agentPreset: mutation.summary.agentPreset } : {}),
       }
       if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
         && filled.origin === existing.origin && filled.blank === existing.blank
-        && filled.agentPreset === existing.agentPreset) return [...summaries]
+      ) return [...summaries]
       return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
     }
     case 'remove':

+ 3 - 4
packages/api/session-controller/src/client/sessions/projection-store.ts

@@ -2,8 +2,8 @@
  * Generic per-session projection value store (push model; see the
  * session-projection subsystem page, docs/subsystems/session-projection.md):
  * the host is the only computation site; the client holds finished
- * whole values per key — `key → { value, seq }` — seeded by a Session page's
- * projections block and updated by Session Controller `projection` frames,
+ * whole values per key — `key → { value, seq }` — seeded by a follow opening
+ * baseline and updated by Session Controller `projection` frames,
  * under the single rule **higher seq wins**. No client-side domain folding
  * exists: a domain ships projection support with zero client code. Per-key
  * bare observable faces feed `useProjection` (ui-renderer binds them).
@@ -40,8 +40,7 @@ export type UseProjection = {
 }
 
 /**
- * Tail-page projections baseline — structurally identical to Session
- * Controller's `SessionProjectionsBlock`, restated here so the
+ * Follow-opening projection baseline, restated here so the
  * React-free store depends only on the type table, not the wire package's
  * response vocabulary.
  */

+ 0 - 11
packages/api/session-controller/src/client/sessions/service.ts

@@ -45,12 +45,6 @@ export interface SessionSummary {
   /** Human-facing label: durable title, project basename, then session id. */
   displayTitle: string
   cwd?: string
-  /**
-   * Agent preset this session's agent was composed from; absent when the
-   * deployment composes no presets. The session header labels what the
-   * session actually runs rather than the deployment's current default.
-   */
-  agentPreset?: string
   parentId?: SessionId
   /** Coarse durable origin for navigation filtering; not a continuation capability. */
   origin?: 'subagent'
@@ -317,10 +311,6 @@ export class ClientSessions implements ISessions {
     return this.manager.refreshSubagents(parentSessionId)
   }
 
-  noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
-    this.manager.noteAgentPreset(sessionId, agentPreset)
-  }
-
   /**
    * Clear the current selection so the layout shows the no-session empty
    * state (new-session affordance and the workspace preselection flow).
@@ -611,7 +601,6 @@ export class ClientSessions implements ISessions {
         ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
         ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
         ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
-        ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
       }
     }
     if (current !== undefined && currentAddress !== undefined) {

+ 9 - 6
packages/api/session-controller/src/client/sessions/session.ts

@@ -45,7 +45,7 @@ export const PAGE_MESSAGES = 50
 export interface SessionOptions {
   /** Catalog-discovered address selecting non-activating subagent transport. */
   address?: SubagentAddress
-  /** Whether the exact direct parent Agent was live at the latest catalog read. */
+  /** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */
   parentAvailable?: boolean
   /**
    * First ACCEPTED prompt on a blank session (fires at most once, on the
@@ -86,7 +86,7 @@ export class Session implements SessionFace {
   private readonly queueMirror = new SessionQueueMirror()
   private running = false
   private address: SubagentAddress | undefined
-  private parentAvailable = false
+  private parentAvailable: boolean | undefined
   /**
    * Sticky send marker, private input of the composerPhase derivation: set
    * synchronously before prompt()'s first await, never reset — the blank →
@@ -144,7 +144,7 @@ export class Session implements SessionFace {
   ) {
     this.projections = options.projections ?? new ProjectionValueStore()
     this.address = options.address
-    this.parentAvailable = options.parentAvailable ?? false
+    this.parentAvailable = options.parentAvailable
     this.notifier = new Notifier(() => {
       this.snapshotCache = this.buildSnapshot()
     })
@@ -467,9 +467,9 @@ export class Session implements SessionFace {
    * Install or clear the catalog-discovered transport address. A changed
    * address rebuilds an already-open window through its new history route.
    * @param address - direct parent/child address, or undefined for ordinary transport.
-   * @param parentAvailable - latest exact-parent availability hint.
+   * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read.
    */
-  configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
+  configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void {
     const same = this.address?.parentSessionId === address?.parentSessionId
       && this.address?.childSessionId === address?.childSessionId
       && this.address?.mode === address?.mode
@@ -623,7 +623,10 @@ export class Session implements SessionFace {
       running: this.running,
       subagent: this.address === undefined
         ? null
-        : { address: this.address, parentAvailable: this.parentAvailable },
+        : {
+          address: this.address,
+          ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
+        },
       removed: this.removed,
       openState: this.openState,
       openError: this.openError,

+ 25 - 11
packages/api/session-controller/src/client/transport.ts

@@ -17,6 +17,7 @@ import type {
   SessionEventEntry,
   SessionPage,
   SessionPageRequest,
+  SessionProjectionBaseline,
 } from '../types.ts'
 
 export {
@@ -30,8 +31,13 @@ export type ClientSessionPageRequest = Omit<SessionPageRequest, 'address' | 'thr
 /** Complete generated `ctx.remote.session` namespace. */
 export type SessionRemote = ClientRemote['session']
 
+/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
+interface SessionJournalPage extends SessionPage {
+  readonly projections?: SessionProjectionBaseline
+}
+
 /** One complete publication from the Session journal stream. */
-export type SessionJournalChange = RemoteJournalChange<SessionPage, SessionEventEntry>
+export type SessionJournalChange = RemoteJournalChange<SessionJournalPage, SessionEventEntry>
 
 type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
 type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
@@ -93,7 +99,7 @@ export function createSessionControlStream(
 
 /** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
 export class SessionEventStream extends RemoteJournalStream<
-  SessionPage,
+  SessionJournalPage,
   SessionEventEntry,
   number,
   ClientSessionPageRequest
@@ -126,15 +132,23 @@ export class SessionEventStream extends RemoteJournalStream<
 
   /** @inheritdoc */
   protected override async * follow(
-    afterSeq: number | undefined,
+    request: ClientSessionPageRequest,
     signal: AbortSignal,
-  ): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number>> {
-    const request = afterSeq === undefined
-      ? { address: this.address }
-      : { address: this.address, afterSeq }
-    for await (const frame of this.remote.session.follow(request, signal)) {
-      if (frame.type === 'opened') {
-        yield frame
+  ): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number, SessionJournalPage>> {
+    for await (const frame of this.remote.session.follow({
+      address: this.address,
+      ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
+    }, signal)) {
+      if (frame.type === 'snapshot') {
+        yield {
+          type: 'opened',
+          cursor: frame.cursor,
+          page: {
+            events: frame.events,
+            hasMore: frame.hasMore,
+            projections: frame.projections,
+          },
+        }
         continue
       }
       const { type: _type, ...entry } = frame
@@ -147,7 +161,7 @@ export class SessionEventStream extends RemoteJournalStream<
     request: ClientSessionPageRequest,
     throughSeq: number,
     signal: AbortSignal,
-  ): Promise<SessionPage> {
+  ): Promise<SessionJournalPage> {
     const result = await this.remote.session.page(
       { address: this.address, throughSeq, ...request },
       signal,

+ 18 - 35
packages/api/session-controller/src/commands.ts

@@ -3,9 +3,7 @@
 import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
-import {
-  PresetMountError, UnknownPresetError, resolveSessionPreset,
-} from '@deepseek-ai/dsh-agent-presets'
+import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
 import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import {
@@ -13,7 +11,8 @@ import {
 } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
+import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -27,7 +26,6 @@ import {
   hasApiSessionSubagentOwner,
   inspectApiSession,
 } from './agent.ts'
-import { buildModelCatalog } from './catalog.ts'
 import type {
   SessionAttachmentRequest,
   SessionAttachmentValue,
@@ -37,8 +35,6 @@ import type {
   SessionCreateValue,
   SessionForkRequest,
   SessionForkValue,
-  SessionModels,
-  SessionModelsRequest,
   SessionPromptRequest,
   SessionPromptValue,
   SessionRenameRequest,
@@ -110,27 +106,10 @@ export class SessionCommandController {
         )
       }
     }
-    const agentPreset = resolveSessionPreset(adopted.session)
+    const agentPreset = this.agents.presetForSession(adopted.session)
     return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
   }
 
-  /**
-   * Read the current selection and advisory model catalog, explicitly resuming the Session.
-   * @param request - Session whose model state is requested.
-   * @returns the current selection and available model groups.
-   */
-  async models(request: SessionModelsRequest): Promise<SessionModels> {
-    const agent = await this.resolveAgent(request.sessionId)
-    const current = this.agents.selectionFor(agent).current
-    const { groups, failures } = await buildModelCatalog(this.ctx)
-    return {
-      current: { ...current },
-      routable: routeServed(this.ctx, current.provider),
-      groups,
-      failures,
-    }
-  }
-
   /**
    * Validate and install one Session-local model selection.
    * @param request - Session identity and requested model selection.
@@ -154,7 +133,7 @@ export class SessionCommandController {
             ? {}
             : { reasoningEffort: resolved.reasoningEffort }),
         }
-        this.agents.selectionFor(agent).current = selected
+        this.agents.selectForNextRequest(agent, selected)
         try {
           await this.ctx.agentDefaultModel.saveSelection(selected)
         } catch (error) {
@@ -210,12 +189,15 @@ export class SessionCommandController {
       && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
       reject('bad-request', 'atSeq must be a non-negative integer', {})
     }
-    let source: SessionReadState
+    let observed: SessionObservation
     try {
-      source = await this.readSessionState(request.sessionId)
+      observed = await this.ctx.sessionQuery.observeSession(request.sessionId)
     } catch (error) {
-      if (error instanceof ApiSessionNotFound) {
-        reject('session-not-found', error.message, { sessionId: request.sessionId })
+      if (error instanceof SessionQueryError
+        && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
+        reject('session-not-found', `session "${request.sessionId}" not found`, {
+          sessionId: request.sessionId,
+        })
       }
       reject(
         'internal',
@@ -223,6 +205,7 @@ export class SessionCommandController {
         {},
       )
     }
+    using source = observed
     const lastSeq = source.events.at(-1)?.seq ?? -1
     const atSeq = request.atSeq
     const anchoredBoundary = atSeq === undefined
@@ -245,7 +228,7 @@ export class SessionCommandController {
     while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
     let workspace: Workspace | undefined
     try {
-      workspace = await this.forkWorkspace(source)
+      workspace = await this.forkWorkspace(source.header)
     } catch (error) {
       reject(
         'internal',
@@ -254,7 +237,7 @@ export class SessionCommandController {
       )
     }
     const childId = SessionId(`session-${randomUUID()}`)
-    const composition = await this.agents.composeAgent(resolveSessionPreset(source))
+    const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
     try {
       const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
       await this.ctx.agents.create({
@@ -262,7 +245,7 @@ export class SessionCommandController {
         seed: source.events.slice(0, cut),
         meta: {
           ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
-          parentSession: source.id,
+          parentSession: source.header.id,
           seedLength: cut,
           ...(composition.agentPreset === undefined
             ? {}
@@ -507,10 +490,10 @@ export class SessionCommandController {
     return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
   }
 
-  private async forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
+  private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
     const workspaces = this.ctx.workspaceRegistry.list()
     const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
-    if (direct !== undefined || source.header.origin !== 'subagent') return direct
+    if (direct !== undefined || source.origin !== 'subagent') return direct
     const lineage = await this.ctx.sessionQuery.traceSession(source.id)
     for (const ancestor of lineage.ancestors) {
       const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))

+ 7 - 7
packages/api/session-controller/src/control.ts

@@ -10,7 +10,7 @@ import type {
   SessionControlBaseline,
   SessionControlFrame,
   SessionJob,
-  SessionProjectionsBlock,
+  SessionProjectionBaseline,
   SessionProjectionValues,
   SessionQueuedItem,
 } from './types.ts'
@@ -22,10 +22,6 @@ export class SessionControlController {
   /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
   constructor(private readonly ctx: Context) {
     ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
-    ctx.on('session/created', (session) => {
-      const jobs = this.jobsFor(this.ctx.agents.get(session.id))
-      if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
-    })
     ctx.inject(['sessionProjections'], (projectionCtx) => {
       projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
         this.broadcast({
@@ -40,6 +36,10 @@ export class SessionControlController {
     ctx.inject(['jobs'], (jobsCtx) => {
       jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
     })
+    ctx.on('session/created', (session) => {
+      const jobs = this.jobsFor(this.ctx.agents.get(session.id))
+      if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
+    })
     ctx.effect(() => () => {
       for (const stream of this.streams) stream.end()
       this.streams.clear()
@@ -82,9 +82,9 @@ export class SessionControlController {
 
   private projectionBaseline(
     sessions: readonly Session[],
-  ): Readonly<Record<SessionId, SessionProjectionsBlock>> {
+  ): Readonly<Record<SessionId, SessionProjectionBaseline>> {
     const registry = this.ctx.get('sessionProjections')
-    const blocks = Object.create(null) as Record<SessionId, SessionProjectionsBlock>
+    const blocks = Object.create(null) as Record<SessionId, SessionProjectionBaseline>
     for (const session of sessions) {
       const snapshot = registry?.snapshot(session)
       blocks[session.id] = snapshot === undefined

+ 101 - 98
packages/api/session-controller/src/history.ts

@@ -2,9 +2,9 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
-import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
+import type {} from '@deepseek-ai/dsh-subagent'
 import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type {
   SessionAddress,
@@ -13,7 +13,7 @@ import type {
   SessionFollowFrame,
   SessionPage,
   SessionPageRequest,
-  SessionProjectionsBlock,
+  SessionProjectionBaseline,
   SessionProjectionValues,
   SessionWireEvent,
 } from './types.ts'
@@ -21,16 +21,18 @@ import type {
 const DEFAULT_MAX_MESSAGES = 50
 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
 
-type SessionSource =
-  | { readonly kind: 'attached'; readonly session: Session }
-  | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
-
 /** Implements cold-safe history operations delegated by the Session Controller. */
 export class SessionHistoryController {
   private readonly closeFollowers = new Set<() => void>()
 
-  /** @param ctx - Host context carrying Session, persistence, and projection services. */
-  constructor(private readonly ctx: Context) {
+  /**
+   * @param ctx - Host context carrying Session query and projection services.
+   * @param promote - starts ordinary Session activation after snapshot delivery.
+   */
+  constructor(
+    private readonly ctx: Context,
+    private readonly promote: (observation: SessionObservation) => void,
+  ) {
     ctx.effect(() => () => {
       for (const close of this.closeFollowers) close()
       this.closeFollowers.clear()
@@ -41,13 +43,13 @@ export class SessionHistoryController {
    * Read one message-aligned history page without activating an Agent.
    * @param request - durable address and backwards-page cursor.
    * @param signal - caller cancellation for persistence reads.
-   * @returns a contiguous event page and a projection baseline on tail reads.
+   * @returns a contiguous event page.
    */
   async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
     validatePageRequest(request)
-    const source = await this.sourceFor(request.address, signal)
+    using source = await this.sourceFor(request.address, signal, false)
     signal.throwIfAborted()
-    const sourceLog = sourceEvents(source)
+    const sourceLog = source.events
     const sourceCursor = sourceLog.at(-1)?.seq ?? -1
     if (request.throughSeq > sourceCursor) {
       reject(
@@ -56,19 +58,20 @@ export class SessionHistoryController {
         {},
       )
     }
-    const events = sourceLog.filter(event => event.seq <= request.throughSeq)
-    if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
+    /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
+    if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
       reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
     }
-    const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
+    const page = paginate(
+      sourceLog,
+      request.beforeSeq,
+      request.maxMessages ?? DEFAULT_MAX_MESSAGES,
+      request.throughSeq,
+    )
     const entries = page.events.map(entryFor)
-    const projections = request.beforeSeq === undefined
-      ? this.projectionsFor(request.address, source, events)
-      : undefined
     return {
       events: entries,
       hasMore: page.hasMore,
-      ...(projections === undefined ? {} : { projections }),
     }
   }
 
@@ -76,13 +79,14 @@ export class SessionHistoryController {
    * Follow events appended after an initial cursor on one durable address.
    * @param request - durable address and last committed sequence already held by the caller.
    * @param signal - stream cancellation owned by the Remote carrier.
-   * @returns an opened cursor followed by gap-free event frames.
+   * @returns a complete opening snapshot followed by gap-free event frames.
    */
   async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
     validateFollowRequest(request)
-    const { address, afterSeq } = request
+    const { address } = request
     const target = addressId(address)
     const buffered: SessionEvent[] = []
+    let snapshotCursor: number | undefined
     let wake: (() => void) | undefined
     const notify = (): void => {
       const resume = wake
@@ -102,35 +106,44 @@ export class SessionHistoryController {
     }, { global: true })
     const disposeCreated = this.ctx.on('session/created', (session) => {
       if (session.id !== target) return
-      // Session construction appends session/end-seed before attachment, so the
-      // marker has no session/event notification. Earlier session/created listeners
-      // may publish later setup events first; this suffix must precede those notifications.
-      const suffix = session.events.slice(session.firstLiveSeq)
+      // 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
+        ? session.firstLiveSeq
+        : snapshotCursor + 1)
       buffered.unshift(...suffix)
       notify()
     }, { global: true })
     const onAbort = (): void => { notify() }
     signal.addEventListener('abort', onAbort, { once: true })
     try {
-      const source = await this.sourceFor(address, signal)
-      const events = [...sourceEvents(source)]
+      using source = await this.sourceFor(address, signal, true)
+      const events = source.events
       signal.throwIfAborted()
-      const cursor = events.at(-1)?.seq ?? -1
-      if (afterSeq !== undefined && afterSeq > cursor) {
-        reject('bad-request', `session event resume seq ${String(afterSeq)} is past cursor ${String(cursor)}`, {})
+      const cursor = source.cursor
+      snapshotCursor = cursor
+      const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
+      yield {
+        type: 'snapshot',
+        header: source.header,
+        cursor,
+        events: page.events.map(entryFor),
+        hasMore: page.hasMore,
+        projections: source.projections === undefined
+          ? { asOfSeq: cursor, values: {} }
+          : projectionBlock(source.projections),
       }
-      let nextSeq = (afterSeq ?? cursor) + 1
-      yield { type: 'opened', cursor }
-      if (afterSeq !== undefined) {
-        for (const event of events) {
-          if (event.seq < nextSeq) continue
-          if (event.seq !== nextSeq) {
-            reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
-          }
-          nextSeq++
-          yield { type: 'event', ...entryFor(event) }
+      if (address.kind === 'session' && source.source === 'prepared') {
+        const promotion = source.retain()
+        try {
+          this.promote(promotion)
+        } catch (error: unknown) {
+          promotion[Symbol.dispose]()
+          throw error
         }
       }
+      let nextSeq = cursor + 1
       while (!follower.closed && !signal.aborted) {
         const item = buffered.shift()
         if (item === undefined) {
@@ -152,50 +165,45 @@ export class SessionHistoryController {
     }
   }
 
-  private async sourceFor(address: SessionAddress, signal: AbortSignal): Promise<SessionSource> {
-    const sessionId = addressId(address)
-    const attached = this.ctx.sessions.get(sessionId)
-    if (attached !== undefined) {
-      validateAddress(address, attached.header, attached.events)
-      return { kind: 'attached', session: attached }
-    }
-    const persistence = this.ctx.get('sessionPersistence')
-    if (persistence === undefined) {
-      reject('internal', 'session persistence is not configured', {})
-    }
-    signal.throwIfAborted()
-    const header = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
-    if (header === undefined || header.cwd === undefined) rejectNotFound(address)
-    const inspected: SessionInspection = await persistence.inspect(sessionId, signal)
-    signal.throwIfAborted()
-    if (inspected.meta.cwd === undefined) rejectNotFound(address)
-    validateAddress(address, inspected.meta, inspected.events)
-    return { kind: 'detached', header: inspected.meta, events: inspected.events }
-  }
-
-  private projectionsFor(
+  private async sourceFor(
     address: SessionAddress,
-    source: SessionSource,
-    events: readonly SessionEvent[],
-  ): SessionProjectionsBlock | undefined {
-    const registry = this.ctx.get('sessionProjections')
-    if (registry === undefined) return undefined
+    signal: AbortSignal,
+    withProjections: boolean,
+  ): Promise<SessionObservation> {
+    const sessionId = addressId(address)
     try {
-      const throughSeq = events.at(-1)?.seq ?? -1
-      const snapshot = source.kind === 'attached' && source.session.seq - 1 === throughSeq
-        ? registry.snapshot(source.session)
-        : registry.restore({}, events, 0).snapshot
-      return {
-        asOfSeq: snapshot.asOfSeq,
-        // Projection definitions validate whole JSON values before snapshot publication.
-        values: snapshot.values as SessionProjectionValues,
+      const observation = await this.ctx.sessionQuery.observeSession(sessionId, {
+        signal,
+        projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',
+      })
+      if (observation.header.cwd === undefined) {
+        observation[Symbol.dispose]()
+        rejectNotFound(address)
       }
-    } catch (error) {
-      if (address.kind === 'session') throw error
-      this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`)
-      return undefined
+      try {
+        validateAddress(address, observation.header, observation.projections)
+      } catch (error: unknown) {
+        observation[Symbol.dispose]()
+        throw error
+      }
+      return observation
+    } catch (error: unknown) {
+      if (error instanceof SessionQueryError
+        && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address)
+      throw error
     }
   }
+
+}
+
+function projectionBlock(
+  snapshot: NonNullable<SessionObservation['projections']>,
+): SessionProjectionBaseline {
+  return {
+    asOfSeq: snapshot.asOfSeq,
+    // Projection definitions validate whole JSON values before snapshot publication.
+    values: snapshot.values as SessionProjectionValues,
+  }
 }
 
 function validatePageRequest(request: SessionPageRequest): void {
@@ -213,9 +221,9 @@ function validatePageRequest(request: SessionPageRequest): void {
 }
 
 function validateFollowRequest(request: SessionFollowRequest): void {
-  if (request.afterSeq !== undefined
-    && (!Number.isSafeInteger(request.afterSeq) || request.afterSeq < -1)) {
-    reject('bad-request', 'afterSeq must be an integer greater than or equal to -1', {})
+  if (request.maxMessages !== undefined
+    && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
+    reject('bad-request', 'maxMessages must be a positive safe integer', {})
   }
 }
 
@@ -226,7 +234,7 @@ function addressId(address: SessionAddress): SessionId {
 function validateAddress(
   address: SessionAddress,
   header: SessionHeader,
-  events: readonly SessionEvent[],
+  projections: SessionObservation['projections'],
 ): void {
   if (address.kind === 'session') {
     if (header.origin === 'subagent') {
@@ -241,24 +249,22 @@ function validateAddress(
       childSessionId: address.childSessionId,
     })
   }
-  let descriptor
-  try {
-    descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0))
-  } catch {
+  const identity = projections?.values.subagent
+  if (identity === null) {
     reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
       parentSessionId: address.parentSessionId,
       childSessionId: address.childSessionId,
       reason: 'corrupt',
     })
   }
-  if (descriptor === undefined) {
+  if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
     reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
       parentSessionId: address.parentSessionId,
       childSessionId: address.childSessionId,
       reason: 'unsupported',
     })
   }
-  if (descriptor.mode !== address.mode) {
+  if (identity.mode !== address.mode) {
     reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
       childSessionId: address.childSessionId,
     })
@@ -279,20 +285,17 @@ function reject(code: string, message: string, details: object): never {
   throw new TypertRemoteFailure({ code, message, details })
 }
 
-function sourceEvents(source: SessionSource): readonly SessionEvent[] {
-  return source.kind === 'attached' ? source.session.events : source.events
-}
-
 function paginate(
   events: readonly SessionEvent[],
   beforeSeq: number | undefined,
   maxMessages: number,
+  throughSeq = events.at(-1)?.seq ?? -1,
 ): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
-  const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
+  const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)
   let count = 0
   let cut = 0
-  for (let index = window.length - 1; index >= 0; index--) {
-    const event = window[index] as SessionEvent
+  for (let index = end - 1; index >= 0; index--) {
+    const event = events[index] as SessionEvent
     if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
     count++
     const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
@@ -305,7 +308,7 @@ function paginate(
       break
     }
   }
-  return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
+  return { events: events.slice(cut, end), hasMore: cut > 0 }
 }
 
 function entryFor(event: SessionEvent): SessionEventEntry {

+ 37 - 17
packages/api/session-controller/src/index.ts

@@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import {
   ApiSessionAgentController,
@@ -14,6 +15,7 @@ import { SessionCommandController } from './commands.ts'
 import { SessionControlController } from './control.ts'
 import { SessionHistoryController } from './history.ts'
 import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
+import { installModelSelectionProjection } from './model-selection-projection.ts'
 import type {
   SessionAttachmentRequest,
   SessionAttachmentValue,
@@ -28,8 +30,6 @@ import type {
   SessionForkValue,
   SessionListRequest,
   SessionListValue,
-  SessionModels,
-  SessionModelsRequest,
   SessionPage,
   SessionPageRequest,
   SessionPromptRequest,
@@ -56,7 +56,7 @@ declare module '@deepseek-ai/cordis' {
 
 /** Session Controller deployment policy. */
 export interface Config {
-  /** Maximum cold Session artifact size read to determine blankness. */
+  /** Maximum cold Session artifact size eligible for one full projection observation. */
   readonly coldBlankProbeMaxBytes?: number
 }
 
@@ -68,6 +68,7 @@ export class SessionController extends TypertRemoteService {
     'attachments',
     'llm',
     'sessions',
+    'sessionProjections',
     'sessionQuery',
     'typert',
     'workspaceRegistry',
@@ -82,17 +83,24 @@ export class SessionController extends TypertRemoteService {
   private readonly controlState: SessionControlController
   private readonly history: SessionHistoryController
   private readonly listState: ApiSessionList
+  private readonly promotions = new Set<Promise<void>>()
 
   /**
    * @param ctx - Host context containing the Session capability assembly.
-   * @param config - cold-list read policy.
+   * @param config - cold-list observation policy.
    */
   constructor(ctx: Context, config: Config) {
     super(ctx, 'sessionController', { namespace: 'session' })
+    installModelSelectionProjection(ctx)
     this.agents = new ApiSessionAgentController(ctx)
     this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
     this.controlState = new SessionControlController(ctx)
-    this.history = new SessionHistoryController(ctx)
+    // Registered before history so reverse-order teardown closes every
+    // follower before waiting for already-admitted promotions.
+    ctx.effect(() => async () => {
+      await Promise.allSettled([...this.promotions])
+    }, 'session-controller.promotions')
+    this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
     this.listState = new ApiSessionList(
       ctx,
       config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
@@ -111,11 +119,33 @@ export class SessionController extends TypertRemoteService {
       ctx.emit('api-session/error', agent.id, errorChain(error))
     })
     ctx.on('session/event', (session, event) => {
+      if (event.type === 'request/header') {
+        const agent = ctx.agents.get(session.id)
+        if (agent?.session === session) this.agents.consumeSelection(
+          agent,
+          event.data.header.config.provider,
+          event.data.header.config.model,
+          event.data.header.config.reasoningEffort,
+        )
+      }
       if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
       ctx.emit('api-session/activity', session.id, event.time)
     })
   }
 
+  private promote(observation: SessionObservation): void {
+    const sessionId = observation.header.id
+    const task = (async () => {
+      using ownedObservation = observation
+      const result = await this.agents.resolveObservedAgent(ownedObservation)
+      if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message)
+    })().catch((error: unknown) => {
+      this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`)
+    })
+    this.promotions.add(task)
+    void task.finally(() => { this.promotions.delete(task) })
+  }
+
   /**
    * Resolve or resume one ordinary Session for another Host API domain.
    * @param sessionId - Session identity whose Agent owns the operation.
@@ -174,16 +204,6 @@ export class SessionController extends TypertRemoteService {
     return this.commands.create(request)
   }
 
-  /**
-   * Read model choices after explicitly resuming the addressed Session.
-   * @param request - Session whose model state is requested.
-   * @returns the current selection and available model groups.
-   */
-  @Remote('models')
-  models(request: SessionModelsRequest): Promise<SessionModels> {
-    return this.commands.models(request)
-  }
-
   /**
    * Select one Session-local model after explicitly resuming the Session.
    * @param request - Session identity and requested model selection.
@@ -260,7 +280,7 @@ export class SessionController extends TypertRemoteService {
    * Read one cold-safe, message-aligned Session history page.
    * @param request - durable address, backward cursor, and page budget.
    * @param signal - cancellation for persistence reads.
-   * @returns one chronological page and optional latest projections.
+   * @returns one chronological page.
    */
   @Remote('page')
   page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
@@ -271,7 +291,7 @@ export class SessionController extends TypertRemoteService {
    * Follow one Session log from its opening or resume cursor.
    * @param request - durable address and last committed sequence already held by the caller.
    * @param signal - cancellation owned by the Remote stream carrier.
-   * @returns an opened cursor followed by gap-free event frames.
+   * @returns a complete opening snapshot followed by gap-free event frames.
    */
   @Remote({ mode: 'stream' })
   follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {

+ 93 - 112
packages/api/session-controller/src/list.ts

@@ -2,10 +2,9 @@
 
 import { stat } from 'node:fs/promises'
 import type { Context } from '@deepseek-ai/cordis'
-import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-agent-presets'
 import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-session-projection-cache'
 import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
@@ -16,11 +15,11 @@ import {
   SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
 } from './types.ts'
 import type {
-  SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem,
+  SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem,
   SessionSearchValue, SessionSummary,
 } from './types.ts'
 
-/** Default maximum artifact size eligible for one cold blankness read. */
+/** Default maximum artifact size eligible for one cold projection observation. */
 export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
 
 const COLD_SUMMARY_BATCH_SIZE = 16
@@ -61,17 +60,6 @@ export function applySessionListMetadata(
     : { blank, lastPromptAt }
 }
 
-/**
- * Fold exact list metadata for an attached Session.
- * @param events - complete attached Session event log.
- * @returns metadata derived from the event prefix.
- */
-export function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
-  let state: SessionListMetadata = { blank: true, lastPromptAt: null }
-  for (const event of events) state = applySessionListMetadata(state, event)
-  return state
-}
-
 /**
  * Return the longest prefix containing at most `maximum` Unicode code points.
  * @param value - source text.
@@ -89,11 +77,11 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin
   return value
 }
 
-/** Owns list projection registration, cold summaries, and authorized search. */
+/** Owns list projection registration, bounded cold summaries, and authorized search. */
 export class ApiSessionList {
   /**
-   * @param ctx - Host context carrying Session, persistence, and projection services.
-   * @param coldBlankProbeMaxBytes - maximum physical artifact size read to verify cold blankness.
+   * @param ctx - Host context carrying Session, query, persistence, and projection services.
+   * @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation.
    */
   constructor(
     private readonly ctx: Context,
@@ -130,14 +118,14 @@ export class ApiSessionList {
    * @returns current list metadata and available projections.
    */
   summaryFor(session: Session): SessionSummary {
-    const metadata = sessionListMetadata(session.events)
     const projections = this.projectionsFor(session.header, session)
+    const metadata = projections?.values.sessionListMetadata
     return {
       sessionId: session.id,
       updatedAt: updatedAt(session.header, metadata),
       running: this.ctx.agents.get(session.id)?.status === 'running',
-      blank: metadata.blank,
-      ...listFields(session.header, session.events),
+      blank: metadata?.blank ?? session.seq === 0,
+      ...listFields(session.header),
       ...(projections === undefined ? {} : { projections }),
     }
   }
@@ -149,41 +137,86 @@ export class ApiSessionList {
    */
   async list(signal?: AbortSignal): Promise<SessionSummary[]> {
     signal?.throwIfAborted()
-    const items = this.ctx.sessions.list().map(session => this.summaryFor(session))
-    const attached = new Set(items.map(item => item.sessionId))
-    const persistence = this.ctx.get('sessionPersistence')
-    if (persistence !== undefined) {
-      const cold = (await persistence.list(signal))
-        .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
-      signal?.throwIfAborted()
-      for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
-        const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
-          .map(async (meta) => {
-            const projections = this.projectionsFor(meta, undefined)
-            const summary = await summarizeCold(
-              this.ctx,
-              persistence,
-              meta,
-              projections?.values.sessionListMetadata,
-              this.coldBlankProbeMaxBytes,
-              signal,
-            )
-            const raced = this.ctx.sessions.get(meta.id)
-            if (raced !== undefined) return this.summaryFor(raced)
-            return { ...summary, ...(projections === undefined ? {} : { projections }) }
-          }))
-        const summaries = settled.map((result) => {
-          if (result.status === 'rejected') throw result.reason
-          return result.value
-        })
-        signal?.throwIfAborted()
-        items.push(...summaries)
+    const records = await this.ctx.sessionQuery.listSessions(signal)
+    signal?.throwIfAborted()
+    const items: SessionSummary[] = []
+    const cold: SessionHeader[] = []
+    for (const record of records) {
+      const live = this.ctx.sessions.get(record.header.id)
+      if (live !== undefined) {
+        items.push(this.summaryFor(live))
+        continue
+      }
+      if (record.header.cwd === undefined) continue
+      cold.push(record.header)
+    }
+    for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
+      const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
+        .map(header => this.summarizeCold(header, signal)))
+      for (const result of settled) {
+        if (result.status === 'rejected') throw result.reason
+        items.push(result.value)
       }
     }
     items.sort((left, right) => right.updatedAt - left.updatedAt)
     return items
   }
 
+  private async summarizeCold(
+    header: SessionHeader,
+    signal: AbortSignal | undefined,
+  ): Promise<SessionSummary> {
+    const cached = this.projectionsFor(header, undefined)
+    const projections = cached?.values.sessionListMetadata?.blank === false
+      ? cached
+      : await this.probeSmallCold(header, signal) ?? cached
+    const raced = this.ctx.sessions.get(header.id)
+    if (raced !== undefined) return this.summaryFor(raced)
+    const metadata = projections?.values.sessionListMetadata
+    return {
+      sessionId: header.id,
+      updatedAt: updatedAt(header, metadata),
+      running: false,
+      // A large or inaccessible cache miss remains unknown and visible.
+      blank: metadata?.blank ?? false,
+      ...listFields(header),
+      ...(projections === undefined ? {} : { projections }),
+    }
+  }
+
+  private async probeSmallCold(
+    header: SessionHeader,
+    signal: AbortSignal | undefined,
+  ): Promise<SessionProjectionHints | undefined> {
+    if (this.coldBlankProbeMaxBytes === 0) return undefined
+    const persistence = this.ctx.get('sessionPersistence')
+    const location = persistence?.locate(header)
+    if (location === undefined) return undefined
+    signal?.throwIfAborted()
+    try {
+      if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined
+    } catch {
+      signal?.throwIfAborted()
+      return undefined
+    }
+    try {
+      using observation = await this.ctx.sessionQuery.observeSession(header.id, {
+        ...(signal === undefined ? {} : { signal }),
+        projectionMode: 'all',
+      })
+      const block = observation.projections
+      return block === undefined
+        ? undefined
+        : { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }
+    } catch (error: unknown) {
+      signal?.throwIfAborted()
+      this.ctx.logger.warn(
+        `api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`,
+      )
+      return undefined
+    }
+  }
+
   /**
    * Search current visible message content without activating any matching Session.
    * @param query - literal message-content query.
@@ -202,10 +235,12 @@ export class ApiSessionList {
       )
     }
     try {
-      const visible = await this.list(signal)
+      const visible = await provider.listSessions(signal)
       signal.throwIfAborted()
-      if (visible.length === 0) return { items: [], hasMore: false }
-      const visibleIds = new Set(visible.map(item => item.sessionId))
+      const visibleIds = new Set(visible
+        .filter(record => record.header.cwd !== undefined)
+        .map(record => record.header.id))
+      if (visibleIds.size === 0) return { items: [], hasMore: false }
       const authorized: SessionSearchItem[] = []
       const acceptedIds = new Set<SessionId>()
       const seenCursors = new Set<SessionSearchCursor>()
@@ -293,15 +328,16 @@ export class ApiSessionList {
   private projectionsFor(
     header: SessionHeader,
     session: Session | undefined,
-  ): SessionProjectionsBlock | undefined {
+  ): SessionProjectionHints | undefined {
     try {
       const block = session === undefined
         ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
-        : this.ctx.get('sessionProjections')?.snapshot(session)
+        : this.ctx.get('sessionProjections')?.cachedSnapshot(session)
       return block !== undefined && Object.keys(block.values).length > 0
         ? {
           asOfSeq: block.asOfSeq,
-          // Projection definitions validate whole JSON values before snapshot publication.
+          // Listing hints contain every currently cached wire value but remain
+          // partial: missing cells and cache rows are never materialized here.
           values: block.values as SessionProjectionValues,
         }
         : undefined
@@ -340,69 +376,14 @@ function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefi
   return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
 }
 
-function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
+function listFields(header: SessionHeader): {
   readonly parentSessionId?: SessionId
   readonly origin?: 'subagent'
   readonly cwd?: string
-  readonly agentPreset?: string
 } {
-  const agentPreset = resolveSessionPreset({ header, events })
   return {
     ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
     ...(header.origin === undefined ? {} : { origin: header.origin }),
     ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
-    ...(agentPreset === undefined ? {} : { agentPreset }),
-  }
-}
-
-async function summarizeCold(
-  ctx: Context,
-  persistence: SessionPersistence,
-  header: SessionHeader,
-  metadata: SessionListMetadata | undefined,
-  blankProbeMaxBytes: number,
-  signal?: AbortSignal,
-): Promise<SessionSummary> {
-  const probed = metadata?.blank === false
-    ? undefined
-    : await probeColdMetadata(ctx, persistence, header, blankProbeMaxBytes, signal)
-  return {
-    sessionId: header.id,
-    updatedAt: updatedAt(header, probed ?? metadata),
-    running: false,
-    blank: metadata?.blank === false ? false : probed?.blank ?? false,
-    ...listFields(header),
-  }
-}
-
-async function probeColdMetadata(
-  ctx: Context,
-  persistence: SessionPersistence,
-  header: SessionHeader,
-  maxBytes: number,
-  signal?: AbortSignal,
-): Promise<SessionListMetadata | undefined> {
-  if (maxBytes === 0) return undefined
-  signal?.throwIfAborted()
-  const location = persistence.locate(header)
-  if (location === undefined) return undefined
-  let size: number
-  try {
-    size = (await stat(location.path)).size
-  } catch {
-    signal?.throwIfAborted()
-    return undefined
-  }
-  if (size > maxBytes) return undefined
-  try {
-    const { events } = await persistence.readFrom(header.id, 0, signal)
-    signal?.throwIfAborted()
-    return sessionListMetadata(events)
-  } catch (error) {
-    signal?.throwIfAborted()
-    ctx.logger.warn(
-      `api-session.list: blank probe for "${header.id}" failed; serving it as visible: ${String(error)}`,
-    )
-    return undefined
   }
 }

+ 83 - 0
packages/api/session-controller/src/model-selection-projection.ts

@@ -0,0 +1,83 @@
+/** Durable model-selection intent and request-use projection. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import { z } from 'zod'
+import type {
+  ModelSelection,
+  ModelSelectionProjection,
+  ModelSelectionProjectionState,
+} from './types.ts'
+
+const modelSelectionSchema = z.object({
+  provider: z.string().min(1),
+  model: z.string().min(1),
+  reasoningEffort: z.string().min(1).optional(),
+}) as unknown as z.ZodType<ModelSelection>
+
+const modelSelectionProjectionStateSchema = z.object({
+  lastUsed: modelSelectionSchema.nullable(),
+  pending: modelSelectionSchema.nullable(),
+}) as unknown as z.ZodType<ModelSelectionProjectionState>
+
+const modelSelectionProjectionSchema = z.object({
+  lastUsed: modelSelectionSchema.nullable(),
+  next: modelSelectionSchema.nullable(),
+}) as unknown as z.ZodType<ModelSelectionProjection>
+
+/**
+ * Advance durable model-selection state by one Session event.
+ * @param state - selection state before the event.
+ * @param event - next committed Session event.
+ * @returns the original or advanced selection state.
+ */
+function applyModelSelectionProjection(
+  state: ModelSelectionProjectionState,
+  event: SessionEvent,
+): ModelSelectionProjectionState {
+  if (event.type === 'model/selection') {
+    return sameSelection(state.pending, event.data)
+      ? state
+      : { lastUsed: state.lastUsed, pending: event.data }
+  }
+  if (event.type !== 'request/header') return state
+  const lastUsed: ModelSelection = {
+    provider: event.data.header.config.provider,
+    model: event.data.header.config.model,
+    ...(event.data.header.config.reasoningEffort === undefined
+      ? {}
+      : { reasoningEffort: String(event.data.header.config.reasoningEffort) }),
+  }
+  const pending = sameSelection(state.pending, lastUsed) ? null : state.pending
+  return sameSelection(state.lastUsed, lastUsed) && pending === state.pending
+    ? state
+    : { lastUsed, pending }
+}
+
+const modelSelectionProjection = {
+  key: 'modelSelection',
+  stateSchema: modelSelectionProjectionStateSchema,
+  init: () => ({ lastUsed: null, pending: null }),
+  apply: applyModelSelectionProjection,
+  wire: {
+    viewSchema: modelSelectionProjectionSchema,
+    view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }),
+  },
+  stateVersion: 2,
+} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState>
+
+function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
+  return left === right || (left !== null && right !== null
+    && left.provider === right.provider
+    && left.model === right.model
+    && left.reasoningEffort === right.reasoningEffort)
+}
+
+/**
+ * Register the durable model-selection projection when the registry is present.
+ * @param ctx - Session Controller context.
+ */
+export function installModelSelectionProjection(ctx: Context): void {
+  ctx.sessionProjections.register(modelSelectionProjection)
+}

+ 59 - 21
packages/api/session-controller/src/types.ts

@@ -6,7 +6,7 @@ import type {
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
+import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -17,12 +17,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
     sessionListMetadata: SessionListMetadata
     /** Host state for the boot-constant image-limit view. */
     imageLimits: null
+    /** Durable model selection already used by a request and still pending for a later request. */
+    modelSelection: ModelSelectionProjectionState
   }
   interface SessionProjectionMap {
     /** Persisted facts used to summarize a Session without activating it. */
     sessionListMetadata: SessionListMetadata
     /** Image-intake limits enforced by the Session prompt endpoint. */
     imageLimits: ImageAttachmentLimits
+    /** Durable model selection already used and selected for the next request. */
+    modelSelection: ModelSelectionProjection
+  }
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /**
+     * Complete validated model selection requested for subsequent prompt
+     * assembly. Log-only: it never enters derived model history.
+     */
+    'model/selection': ModelSelection
   }
 }
 
@@ -34,10 +48,17 @@ export interface SessionListMetadata {
   readonly lastPromptAt: number | null
 }
 
-/** Projection values and the durable event position they represent. */
-export interface SessionProjectionsBlock {
+/** Every available cached wire value used as partial, possibly stale Session-list hints. */
+export interface SessionProjectionHints {
   readonly asOfSeq: number
-  /** Provider-validated values across the merge-extensible projection key space. */
+  /** Provider-validated values present in the cache; omitted keys remain unknown. */
+  readonly values: SessionProjectionValues
+}
+
+/** Complete projection values at an exact Session event cursor. */
+export interface SessionProjectionBaseline {
+  readonly asOfSeq: number
+  /** Provider-validated values; omitted keys are absent capabilities at this cut. */
   readonly values: SessionProjectionValues
 }
 
@@ -62,6 +83,22 @@ export interface ModelSelection {
   readonly reasoningEffort?: string
 }
 
+/** Host fold state for durable model selection. */
+export interface ModelSelectionProjectionState {
+  /** Selection consumed by the latest recorded model request. */
+  readonly lastUsed: ModelSelection | null
+  /** Later user selection not yet consumed by a matching model request. */
+  readonly pending: ModelSelection | null
+}
+
+/** Client view of the durable model-selection fold. */
+export interface ModelSelectionProjection {
+  /** Selection consumed by the latest recorded model request. */
+  readonly lastUsed: ModelSelection | null
+  /** Selection the next request should use, falling back to {@link lastUsed}. */
+  readonly next: ModelSelection | null
+}
+
 /** One adapter-owned reasoning effort for an exact model route. */
 export interface ModelReasoningEffort {
   readonly id: string
@@ -97,10 +134,11 @@ export interface ModelCatalogFailure {
   readonly message: string
 }
 
-/** Detached model-directory snapshot for one Session. */
-export interface SessionModels {
-  readonly current: ModelSelection
-  readonly routable: boolean
+/** Host-generation model catalog and the default used by unconfigured Sessions. */
+export interface ModelCatalog {
+  readonly default: ModelSelection
+  /** Provider routes currently able to serve a request, including empty catalogs. */
+  readonly routableProviders: readonly string[]
   readonly groups: readonly ModelProviderGroup[]
   readonly failures: readonly ModelCatalogFailure[]
 }
@@ -120,8 +158,7 @@ export interface SessionSummary {
   readonly parentSessionId?: SessionId
   readonly origin?: 'subagent'
   readonly cwd?: string
-  readonly agentPreset?: string
-  readonly projections?: SessionProjectionsBlock
+  readonly projections?: SessionProjectionHints
 }
 
 /** One session-content search result. */
@@ -220,11 +257,6 @@ export interface SessionCreateValue {
   readonly agentPreset?: string
 }
 
-/** Model-directory request. */
-export interface SessionModelsRequest {
-  readonly sessionId: SessionId
-}
-
 /** Session model-selection request. */
 export interface SessionSelectModelRequest extends ModelSelection {
   readonly sessionId: SessionId
@@ -352,22 +384,28 @@ export interface SessionPageRequest {
   readonly maxMessages?: number
 }
 
-/** One live event request, optionally resuming after an already-applied event. */
+/** One live event request for a durable Session address. */
 export interface SessionFollowRequest {
   readonly address: SessionAddress
-  readonly afterSeq?: number
+  readonly maxMessages?: number
 }
 
 /** One contiguous backwards page of a Session log. */
 export interface SessionPage {
   readonly events: readonly SessionEventEntry[]
   readonly hasMore: boolean
-  readonly projections?: SessionProjectionsBlock
 }
 
-/** Initial cursor followed by ordered events appended after that cursor. */
+/** Complete opening window followed by ordered events appended after its cursor. */
 export type SessionFollowFrame =
-  | { readonly type: 'opened'; readonly cursor: number }
+  | {
+    readonly type: 'snapshot'
+    readonly header: SessionHeader
+    readonly cursor: number
+    readonly events: readonly SessionEventEntry[]
+    readonly hasMore: boolean
+    readonly projections: SessionProjectionBaseline
+  }
   | ({ readonly type: 'event' } & SessionEventEntry)
 
 /** One pending inbox occurrence in the authoritative queue snapshot. */
@@ -396,7 +434,7 @@ export interface SessionJob {
 export interface SessionControlBaseline {
   readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
   readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
-  readonly projections: Readonly<Record<SessionId, SessionProjectionsBlock>>
+  readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
 }
 
 /** One finished projection value and its durable watermark. */

+ 143 - 27
packages/api/session-controller/tests/agent.host.spec.ts

@@ -4,8 +4,10 @@ import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -16,6 +18,8 @@ import {
   ApiSessionSubagentOwnership,
   inspectApiSession,
 } from '../src/agent.ts'
+import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
+import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 const roots: Context[] = []
 
@@ -29,6 +33,9 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
   await ctx.plugin(TypertRegistry)
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
+  installSessionReadTestServices(ctx)
+  ctx.sessionProjections.register(agentPresetProjectionDefinition)
+  installModelSelectionProjection(ctx)
   ctx.provide('agentDefaultModel', {
     currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
     saveSelection: () => Promise.resolve(),
@@ -45,6 +52,10 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
   }
 }
 
+function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
+  return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
+}
+
 function agent(ctx: Context, meta: SessionHeader): Agent {
   const session = ctx.sessions.create(meta.id, { meta })
   return { id: meta.id, session, status: 'idle', ctx } as Agent
@@ -67,48 +78,94 @@ describe('ApiSession identity failures', () => {
       .toContain('belongs to "/existing"')
   })
 
-  it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
+  it('maps absent and cwd-less point observations to not found', async () => {
     const ctx = new Context()
     roots.push(ctx)
+    await ctx.plugin(SessionStore)
+    installSessionReadTestServices(ctx)
     await expect(inspectApiSession(ctx, SessionId('missing')))
-      .rejects.toThrow('session persistence is not configured')
+      .rejects.toBeInstanceOf(ApiSessionNotFound)
 
-    const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
-    const disposeMissing = ctx.provide('sessionPersistence', {
+    const inspect = vi.fn(() => Promise.resolve(undefined))
+    const disposeMissing = providePersistence(ctx, {
       list: () => Promise.resolve([]),
       inspect,
-    } as never)
+    })
     await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
-    expect(inspect).not.toHaveBeenCalled()
+    expect(inspect).toHaveBeenCalledOnce()
     disposeMissing()
 
     const listed = header('cwd-less-catalog', null)
-    const disposeListed = ctx.provide('sessionPersistence', {
+    const disposeListed = providePersistence(ctx, {
       list: () => Promise.resolve([listed]),
-      inspect,
-    } as never)
+      inspect: () => Promise.resolve({ meta: listed, events: [] }),
+    })
     await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
     disposeListed()
 
     const catalog = header('cwd-less-inspect')
     const inspected = header('cwd-less-inspect', null)
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([catalog]),
       inspect: () => Promise.resolve({ meta: inspected, events: [] }),
-    } as never)
+    })
     await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
   })
+
+  it('forwards an explicit inspection signal', async () => {
+    const ctx = new Context()
+    roots.push(ctx)
+    await ctx.plugin(SessionStore)
+    installSessionReadTestServices(ctx)
+    const meta = header('signalled-inspection')
+    const inspect = vi.fn(() => Promise.resolve({ meta, events: [] }))
+    providePersistence(ctx, { inspect })
+    const signal = new AbortController().signal
+
+    await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] })
+    expect(inspect).toHaveBeenCalledWith(meta.id, signal)
+  })
 })
 
 describe('ApiSession Agent lookup and recovery', () => {
+  it('resumes directly from a retained observation and rejects an invalid observed header', async () => {
+    const { ctx, agents } = await harness()
+    const meta = header('observed-resume')
+    const resumed = unpublishedAgent(ctx, meta)
+    const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
+      agent: resumed,
+      dispose: () => Promise.resolve(),
+    })
+    const observed = {
+      source: 'prepared',
+      header: meta,
+      events: [],
+      cursor: -1,
+      projections: { asOfSeq: -1, values: {} },
+      retain: vi.fn(),
+      [Symbol.dispose]: vi.fn(),
+    } as unknown as SessionObservation
+
+    await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed })
+    expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
+
+    const invalid = {
+      ...observed,
+      header: header('observed-without-cwd', null),
+    } as SessionObservation
+    await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
+      error: { code: 'session-not-found' },
+    })
+  })
+
   it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
     const { ctx } = await harness()
     const live = agent(ctx, header('live'))
     ctx.agents.register(live)
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
-    } as never)
+    })
     const host = ctx.typert.contexts.getHost('agent')
     if (host === undefined) throw new Error('Agent Context resolver was not registered')
 
@@ -119,10 +176,10 @@ describe('ApiSession Agent lookup and recovery', () => {
   it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
     const ordinary = await harness()
     const ordinaryMeta = header('ordinary-race')
-    ordinary.ctx.provide('sessionPersistence', {
+    providePersistence(ordinary.ctx, {
       list: () => Promise.resolve([ordinaryMeta]),
       inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
-    } as never)
+    })
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
       ordinary.ctx.agents.register(winner)
@@ -132,10 +189,10 @@ describe('ApiSession Agent lookup and recovery', () => {
 
     const child = await harness()
     const childMeta = header('child-race')
-    child.ctx.provide('sessionPersistence', {
+    providePersistence(child.ctx, {
       list: () => Promise.resolve([childMeta]),
       inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
-    } as never)
+    })
     vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
       child.ctx.sessions.create(childMeta.id, {
         meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
@@ -149,25 +206,84 @@ describe('ApiSession Agent lookup and recovery', () => {
 
   it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
     const missing = await harness()
-    missing.ctx.provide('sessionPersistence', {
+    providePersistence(missing.ctx, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
-    } as never)
+    })
     await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
       error: { code: 'session-not-found' },
     })
 
     const failed = await harness()
     const meta = header('failed')
-    failed.ctx.provide('sessionPersistence', {
+    providePersistence(failed.ctx, {
       list: () => Promise.resolve([meta]),
       inspect: () => Promise.resolve({ meta, events: [] }),
-    } as never)
+    })
     vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
     await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
       error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
     })
   })
+
+  it('requires projected observations before activation', async () => {
+    const { agents } = await harness()
+    const meta = header('unprojected-observation')
+    const observed = {
+      source: 'prepared',
+      header: meta,
+      events: [],
+      cursor: -1,
+      retain: vi.fn(),
+      [Symbol.dispose]: vi.fn(),
+    } as unknown as SessionObservation
+
+    expect(() => agents.presetForObservation(observed)).toThrow(
+      'Agent activation requires a projected Session observation',
+    )
+  })
+})
+
+describe('ApiSession model selection', () => {
+  it('requires the model-selection projection', async () => {
+    const { ctx, agents } = await harness()
+    const live = agent(ctx, header('missing-model-projection'))
+    vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
+
+    expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
+  })
+
+  it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
+    const { ctx, agents } = await harness()
+    const logged = agent(ctx, header('logged-model'))
+    logged.session.append('request/header', {
+      header: { config: { provider: 'logged-provider', model: 'logged-model' } },
+      reason: 'initial',
+    })
+    expect(agents.selectionFor(logged).current).toEqual({
+      provider: 'logged-provider',
+      model: 'logged-model',
+    })
+
+    const pending = agent(ctx, header('pending-model'))
+    const selection = agents.selectionFor(pending)
+    agents.selectForNextRequest(pending, {
+      provider: 'selected-provider',
+      model: 'selected-model',
+      reasoningEffort: 'high' as never,
+    })
+    expect(selection.current).toMatchObject({
+      provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
+    })
+    expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
+    expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
+    expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
+    expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
+    expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
+
+    const untouched = agent(ctx, header('uninstalled-model'))
+    expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
+  })
 })
 
 describe('ApiSession create or adoption', () => {
@@ -252,10 +368,10 @@ describe('ApiSession create or adoption', () => {
       time: 1,
       data: { agentPreset: 'minimal' },
     }] as SessionEvent[]
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect: () => Promise.resolve({ meta, events }),
-    } as never)
+    })
     ctx.provide('agentPresets', {
       resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
       mount: () => Promise.resolve(),
@@ -278,10 +394,10 @@ describe('ApiSession create or adoption', () => {
   it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
     const child = await harness()
     const childMeta = header('resume-child-race')
-    child.ctx.provide('sessionPersistence', {
+    providePersistence(child.ctx, {
       list: () => Promise.resolve([childMeta]),
       inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
-    } as never)
+    })
     child.ctx.provide('agentPresets', {
       resolve: () => {
         child.ctx.sessions.create(childMeta.id, {
@@ -297,10 +413,10 @@ describe('ApiSession create or adoption', () => {
 
     const conflict = await harness()
     const stored = header('stored-cwd-conflict', '/stored')
-    conflict.ctx.provide('sessionPersistence', {
+    providePersistence(conflict.ctx, {
       list: () => Promise.resolve([stored]),
       inspect: () => Promise.resolve({ meta: stored, events: [] }),
-    } as never)
+    })
     await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
       .rejects.toBeInstanceOf(ApiSessionCwdConflict)
   })

+ 24 - 11
packages/api/session-controller/tests/commands-create-fork.host.spec.ts

@@ -11,6 +11,7 @@ import {
   ApiSessionCwdConflict,
 } from '../src/agent.ts'
 import { SessionCommandController } from '../src/commands.ts'
+import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
   await expect(operation).rejects.toMatchObject({ failure: { code } })
@@ -20,6 +21,8 @@ function controllerAgents(overrides: object = {}): ApiSessionAgentController {
   return {
     ensureSession: () => Promise.resolve(),
     composeAgent: () => Promise.resolve({ setup: () => {} }),
+    presetForSession: () => undefined,
+    presetForObservation: () => undefined,
     ...overrides,
   } as unknown as ApiSessionAgentController
 }
@@ -28,6 +31,7 @@ async function baseContext(): Promise<Context> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
+  installSessionReadTestServices(ctx)
   ctx.provide('agentDefaultModel', {
     currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
     saveSelection: () => Promise.resolve(),
@@ -167,23 +171,23 @@ function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
 }
 
 describe('Session fork failures', () => {
-  it('distinguishes missing cold sources from unavailable persistence', async () => {
-    const unavailable = await baseContext()
-    unavailable.provide('workspaceRegistry', { list: () => [] } as never)
+  it('maps missing cold sources with and without persistence', async () => {
+    const withoutPersistence = await baseContext()
+    withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
     const unavailableController = new SessionCommandController(
-      unavailable, controllerAgents(), '/default',
+      withoutPersistence, controllerAgents(), '/default',
     )
     await expectFailure(unavailableController.fork({
       sessionId: SessionId('missing'),
-    }), 'internal')
-    await unavailable.fiber.dispose()
+    }), 'session-not-found')
+    await withoutPersistence.fiber.dispose()
 
     const missing = await baseContext()
     missing.provide('workspaceRegistry', { list: () => [] } as never)
-    missing.provide('sessionPersistence', {
+    missing.provide('sessionPersistence', testSessionPersistence(missing, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
-    } as never)
+    }) as never)
     const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
     await expectFailure(missingController.fork({
       sessionId: SessionId('missing'),
@@ -191,6 +195,16 @@ describe('Session fork failures', () => {
     await missing.fiber.dispose()
   })
 
+  it('maps an observation failure to an internal fork error', async () => {
+    const ctx = await baseContext()
+    ctx.provide('workspaceRegistry', { list: () => [] } as never)
+    vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
+    const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
+
+    await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
+    await ctx.fiber.dispose()
+  })
+
   it('rejects a Session with no completed turn', async () => {
     const ctx = await baseContext()
     ctx.provide('workspaceRegistry', { list: () => [] } as never)
@@ -204,9 +218,8 @@ describe('Session fork failures', () => {
   it('maps lineage lookup and Agent creation failures', async () => {
     const lineage = await baseContext()
     lineage.provide('workspaceRegistry', { list: () => [] } as never)
-    lineage.provide('sessionQuery', {
-      traceSession: () => Promise.reject(new Error('lineage unavailable')),
-    } as never)
+    vi.spyOn(lineage.sessionQuery, 'traceSession')
+      .mockRejectedValue(new Error('lineage unavailable'))
     const child = completedSession(lineage, 'subagent-source', '/workspace', {
       parentSession: SessionId('parent'),
       origin: 'subagent',

+ 47 - 10
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -3,12 +3,13 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
+import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import { describe, expect, it, vi } from 'vitest'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { SessionCommandController } from '../src/commands.ts'
+import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 async function commandHarness(): Promise<{
   ctx: Context
@@ -142,10 +143,11 @@ async function persistedController(
   await ctx.plugin(SessionStore)
   const sessionId = SessionId('cold-attachment')
   const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
-  ctx.provide('sessionPersistence', {
+  ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
     list: () => Promise.resolve([meta]),
     inspect: () => Promise.resolve({ meta, events }),
-  } as never)
+  }) as never)
+  installSessionReadTestServices(ctx)
   ctx.provide('attachments', { readImage } as never)
   const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
   return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
@@ -158,15 +160,31 @@ describe('Session attachment authorization', () => {
     const inserted = imageRef('inserted')
     const streamed = imageRef('streamed')
     const events = [
-      event('fixture/direct', 0, {
+      { ...event('fixture/direct', 0, {
         content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
           type: 'tool-result', content: [{ type: 'image', attachment: nested }],
         }],
+      }), ignorable: true as const },
+      { ...event('assistant/message', 1, {
+        turn: 1,
+        step: 1,
+        message: createAssistantMessage({
+          content: [{ type: 'image', attachment: message }],
+          source: { provider: 'fixture', model: 'fixture' },
+        }),
+      }), surfaceOp: 'append' as const },
+      event('agent/inbox/spliced', 2, {
+        target: 'next-turn',
+        start: 0,
+        inserted: [createUserMessage({
+          content: [{ type: 'image', attachment: inserted }],
+          source: { kind: 'user' },
+        })],
       }),
-      event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }),
-      event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }),
       event('assistant/chunk', 3, {
-        chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } },
+        turn: 1,
+        step: 1,
+        chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
       }),
     ]
     const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
@@ -183,6 +201,7 @@ describe('Session attachment authorization', () => {
   it('maps missing persistence identities and attachment backend failures', async () => {
     const noPersistence = new Context()
     await noPersistence.plugin(SessionStore)
+    installSessionReadTestServices(noPersistence)
     const noPersistenceController = new SessionCommandController(
       noPersistence,
       { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -190,14 +209,15 @@ describe('Session attachment authorization', () => {
     )
     await expectFailure(noPersistenceController.attachment({
       sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
-    }), 'internal')
+    }), 'session-not-found')
 
     const missing = new Context()
     await missing.plugin(SessionStore)
-    missing.provide('sessionPersistence', {
+    missing.provide('sessionPersistence', testSessionPersistence(missing, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
-    } as never)
+    }) as never)
+    installSessionReadTestServices(missing)
     const missingController = new SessionCommandController(
       missing,
       { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -223,4 +243,21 @@ describe('Session attachment authorization', () => {
       await fixture.ctx.fiber.dispose()
     }
   })
+
+  it('maps a cold observation failure to an internal authorization error', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    installSessionReadTestServices(ctx)
+    vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
+    const controller = new SessionCommandController(
+      ctx,
+      { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
+      '/workspace',
+    )
+
+    await expectFailure(controller.attachment({
+      sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
+    }), 'internal')
+    await ctx.fiber.dispose()
+  })
 })

+ 23 - 12
packages/api/session-controller/tests/control-jobs.host.spec.ts

@@ -104,6 +104,29 @@ describe('Session control jobs baseline', () => {
 })
 
 describe('Session control jobs updates', () => {
+  it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
+    const { ctx, control } = await harness(true)
+    const abort = new AbortController()
+    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
+    await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
+    const task = producer('already running')
+    const id = ctx.jobs.start(task.spec)
+    await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
+
+    const created = ctx.sessions.create(SessionId('late-session'))
+    await expect(iterator.next()).resolves.toMatchObject({
+      value: {
+        type: 'jobs',
+        sessionId: created.id,
+        jobs: [expect.objectContaining({ id, label: 'already running' })],
+      },
+    })
+
+    task.settle({ status: 'completed' })
+    abort.abort()
+    await iterator.return?.()
+  })
+
   it('pushes the owner whole set on registration, stopping, and settlement', async () => {
     const { ctx, session, agent, control } = await harness(true)
     const abort = new AbortController()
@@ -200,16 +223,4 @@ describe('Session control jobs updates', () => {
     expect(task.reads.count).toBe(0)
   })
 
-  it('publishes existing unowned jobs for a session created after stream open', async () => {
-    const { ctx, control } = await harness(true)
-    const abort = new AbortController()
-    const collected = collectJobs(control.control(abort.signal), 2, abort)
-
-    ctx.jobs.start(producer('visible to every caller').spec)
-    const created = ctx.sessions.create()
-
-    const frames = await collected
-    const forNew = frames.filter(frame => frame.sessionId === created.id)
-    expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller')
-  })
 })

+ 118 - 4
packages/api/session-controller/tests/controller.host.spec.ts

@@ -6,7 +6,8 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import { describe, expect, it, vi } from 'vitest'
 import SessionController from '../src/index.ts'
-import { createSessionTestController } from './test-remote.ts'
+import type { ApiSessionAgentController } from '../src/agent.ts'
+import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
 
 const defaults = {
   defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
@@ -31,10 +32,10 @@ describe('SessionController facade', () => {
     }
     const events: SessionEvent[] = []
     const inspect = vi.fn(() => Promise.resolve({ meta: header, events }))
-    ctx.provide('sessionPersistence', {
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
       list: () => Promise.resolve([header]),
       inspect,
-    } as never)
+    }) as never)
     const controller = createSessionTestController(ctx, defaults)
     const status = vi.fn()
     const failure = vi.fn()
@@ -54,6 +55,10 @@ describe('SessionController facade', () => {
       ctx,
     } as Agent
     ctx.agents.register(agent)
+    const consumeSelection = vi.spyOn(
+      (controller as unknown as { agents: ApiSessionAgentController }).agents,
+      'consumeSelection',
+    )
 
     await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
     await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
@@ -67,6 +72,21 @@ describe('SessionController facade', () => {
     expect(status).toHaveBeenCalledWith(sessionId, true)
     expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
     expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
+    session.append('request/header', {
+      header: { config: { provider: 'fixture', model: 'fixture-model' } },
+      reason: 'initial',
+    })
+    expect(consumeSelection).toHaveBeenCalledWith(
+      agent, 'fixture', 'fixture-model', undefined,
+    )
+    const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
+      meta: { cwd: '/workspace' },
+    })
+    unowned.append('request/header', {
+      header: { config: { provider: 'fixture', model: 'other-model' } },
+      reason: 'initial',
+    })
+    expect(consumeSelection).toHaveBeenCalledTimes(1)
 
     const abort = new AbortController()
     const iterator = controller.follow({
@@ -74,9 +94,103 @@ describe('SessionController facade', () => {
     }, abort.signal)[Symbol.asyncIterator]()
     await expect(iterator.next()).resolves.toMatchObject({
       done: false,
-      value: { type: 'opened', cursor: 0 },
+      value: { type: 'snapshot', cursor: 1 },
     })
     abort.abort()
     await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
   })
+
+  it.each(['success', 'domain-error', 'throw'] as const)(
+    'promotes a prepared follow observation in the background: %s',
+    async (outcome) => {
+      const ctx = new Context()
+      await ctx.plugin(SessionStore)
+      await ctx.plugin(AgentRegistry)
+      const sessionId = SessionId(`background-${outcome}`)
+      const header: SessionHeader = {
+        version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
+      }
+      ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+        list: () => Promise.resolve([header]),
+        inspect: () => Promise.resolve({ meta: header, events: [] }),
+      }) as never)
+      const controller = createSessionTestController(ctx, defaults)
+      const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
+      const apiError = vi.fn()
+      ctx.on('api-session/error', apiError)
+      const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+      const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
+      const resolve = vi.spyOn(agents, 'resolveObservedAgent')
+      if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
+      else if (outcome === 'domain-error') {
+        resolve.mockResolvedValue({
+          error: { code: 'internal', message: 'activation unavailable', details: {} },
+        })
+      } else {
+        resolve.mockRejectedValue(new Error('activation crashed'))
+      }
+      const abort = new AbortController()
+      const iterator = controller.follow({
+        address: { kind: 'session', sessionId },
+      }, abort.signal)[Symbol.asyncIterator]()
+
+      await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
+      const waiting = iterator.next()
+      await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
+      if (outcome === 'domain-error') {
+        await vi.waitFor(() => {
+          expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
+        })
+      } else if (outcome === 'throw') {
+        await vi.waitFor(() => {
+          expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
+        })
+      } else {
+        expect(apiError).not.toHaveBeenCalled()
+      }
+      abort.abort()
+      await expect(waiting).resolves.toMatchObject({ done: true })
+      await ctx.fiber.dispose()
+    },
+  )
+
+  it('waits for an admitted background promotion during teardown', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const sessionId = SessionId('background-disposal')
+    const header: SessionHeader = {
+      version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
+    }
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+      list: () => Promise.resolve([header]),
+      inspect: () => Promise.resolve({ meta: header, events: [] }),
+    }) as never)
+    const controller = createSessionTestController(ctx, defaults)
+    const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
+      started.resolve(undefined)
+      await release.promise
+      return {
+        agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
+      }
+    })
+    const iterator = controller.follow({
+      address: { kind: 'session', sessionId },
+    }, new AbortController().signal)[Symbol.asyncIterator]()
+
+    await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
+    const waiting = iterator.next()
+    await started.promise
+    let disposed = false
+    const disposal = ctx.fiber.dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+
+    release.resolve(undefined)
+    await disposal
+    await expect(waiting).resolves.toMatchObject({ done: true })
+  })
 })

+ 38 - 37
packages/api/session-controller/tests/fake-api.client.ts

@@ -12,9 +12,9 @@ import type {
   SessionControlFrame,
   SessionFollowFrame,
   SessionFollowRequest,
-  SessionModels,
   SessionPage,
   SessionPageRequest,
+  SessionProjectionBaseline,
   SessionSelectModelRequest,
   SessionSelectModelValue,
 } from '@deepseek-ai/dsh-api-session-controller/types'
@@ -23,6 +23,7 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteStream,
+  RemoteStreamError,
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -54,12 +55,6 @@ function addressSessionId(address: SessionAddress): SessionId {
   return address.kind === 'session' ? address.sessionId : address.childSessionId
 }
 
-function addressKey(address: SessionAddress): string {
-  return address.kind === 'session'
-    ? `session:${address.sessionId}`
-    : `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}`
-}
-
 export interface Deferred<T> {
   promise: Promise<T>
   resolve(value: T): void
@@ -128,12 +123,6 @@ export class FakeApiClient implements IApiClient {
   onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
     () => Promise.resolve(ok({ items: [], hasMore: false }))
   onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
-  onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
-    current: { provider: 'fixture', model: 'fixture' },
-    routable: true,
-    groups: [],
-    failures: [],
-  }))
   onSelectModel: (payload: SessionSelectModelRequest) => Promise<RpcResponse<SessionSelectModelValue>> =
     payload => Promise.resolve(ok({
       selected: {
@@ -147,7 +136,7 @@ export class FakeApiClient implements IApiClient {
   onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
   onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
   onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
-  => Promise<RpcResponse<SessionPage>> =
+  => Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
     () => Promise.resolve(ok({ events: [], hasMore: false }))
 
   onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -186,7 +175,6 @@ export class FakeApiClient implements IApiClient {
   private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
   private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
   private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
-  private readonly openingPages = new Map<string, Promise<RpcResponse<SessionPage>>>()
   /** Optional Host opening cursor override for stale-page and reconnect tests. */
   followCursor: number | undefined
   controlBaseline: SessionControlBaseline = {
@@ -292,7 +280,12 @@ export class FakeApiClient implements IApiClient {
 
   readonly llm: IApiClient['llm'] = {
     providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
-    models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
+    models: payload => this.record('llm.models', payload, Promise.resolve(ok({
+      default: { provider: 'fixture', model: 'fixture' },
+      routableProviders: [],
+      groups: [],
+      failures: [],
+    }))),
     discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
   }
 
@@ -312,7 +305,6 @@ export class FakeApiClient implements IApiClient {
           return this.remoteResult('session.search', payload, this.onSearch(payload))
         },
         create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
-        models: payload => this.remoteResult('session.models', payload, this.onModels(payload)),
         selectModel: payload => this.remoteResult(
           'session.selectModel',
           payload,
@@ -412,14 +404,6 @@ export class FakeApiClient implements IApiClient {
   }
 
   private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
-    const key = addressKey(request.address)
-    if (request.beforeSeq === undefined && request.maxMessages === 50) {
-      const opening = this.openingPages.get(key)
-      if (opening !== undefined) {
-        this.openingPages.delete(key)
-        return this.fetchPage(request, opening)
-      }
-    }
     return this.fetchPage(request)
   }
 
@@ -466,25 +450,42 @@ export class FakeApiClient implements IApiClient {
   ): AsyncGenerator<SessionFollowFrame> {
     const sessionId = addressSessionId(request.address)
     this.followStarts.push(sessionId)
-    const key = addressKey(request.address)
-    const initialPage = this.followCursor === undefined
-      ? this.onHistory({ sessionId, maxMessages: 50 })
-      : undefined
-    if (initialPage !== undefined) this.openingPages.set(key, initialPage)
+    this.calls.push({ method: 'session.follow', payload: request })
     const conns = this.followConns.get(sessionId) ?? []
     if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns)
     const stream = this.openValueStream(conns, signal)
     try {
-      const page = initialPage === undefined ? undefined : (await initialPage).result
-      const cursor = this.followCursor
-        ?? (page?.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1)
-      yield { type: 'opened', cursor }
+      const response = await this.onHistory({
+        sessionId,
+        maxMessages: request.maxMessages ?? 50,
+      })
+      if (!response.result.ok) {
+        throw new RemoteStreamError(
+          response.result.error.code,
+          response.result.error.message,
+          response.result.error.details,
+        )
+      }
+      const page = response.result.value
+      const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1
+      yield {
+        type: 'snapshot',
+        header: {
+          version: 0,
+          id: sessionId,
+          createdAt: 0,
+          ...(request.address.kind === 'subagent'
+            ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
+            : {}),
+        },
+        cursor,
+        events: page.events.filter(entry => entry.event.seq <= cursor),
+        hasMore: page.hasMore,
+        projections: page.projections ?? { asOfSeq: cursor, values: {} },
+      }
       yield* stream.values
     } finally {
       stream.dispose()
-      if (initialPage !== undefined && this.openingPages.get(key) === initialPage) {
-        this.openingPages.delete(key)
-      }
     }
   }
 

+ 31 - 4
packages/api/session-controller/tests/manager.client.spec.ts

@@ -310,9 +310,15 @@ describe('subagent catalogs', () => {
     })
     await manager.get(S2).open()
     await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
-    expect(api.callsOf('subagent.history')).toEqual([
-      { parentSessionId: S1, childSessionId: S2, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
+    expect(api.callsOf('session.follow')).toEqual([
+      {
+        address: {
+          kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
+        },
+        maxMessages: 50,
+      },
     ])
+    expect(api.callsOf('subagent.history')).toEqual([])
     expect(api.callsOf('subagent.prompt')).toEqual([
       {
         parentSessionId: S1, childSessionId: S2, mode: 'continuable',
@@ -507,6 +513,7 @@ describe('subagent catalogs', () => {
       api.onSubagentList = () => first.promise
       const manager = new SessionManager(api, fakeRemote(api), root)
       const refresh = manager.refreshSubagents(root)
+      manager.setSubagentCatalogOpen(root, true)
 
       // A membership frame arrives while the pull is in flight; the debounced
       // refresh it schedules fires 50ms later and is coalesced into the pull —
@@ -768,17 +775,37 @@ describe('connected generation', () => {
     expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
   })
 
-  it('reloads the durable parent address for a restored child selection', async () => {
+  it('retains the durable parent address and refreshes its catalogs across reconnect', async () => {
     const api = new FakeApiClient()
     const address = {
       parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
     }
+    const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
+    const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
+    api.onSubagentList = payload => (
+      (payload as { parentSessionId: SessionId }).parentSessionId === S1
+        ? parent.promise
+        : child.promise
+    )
     const manager = new SessionManager(api, fakeRemote(api), S2, address)
 
     manager.handleConnected()
+    expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
+    parent.resolve(ok({ entries: [], parentAvailable: true }))
+    child.resolve(ok({ entries: [], parentAvailable: true }))
 
     await vi.waitFor(() => {
-      expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
+      expect(api.callsOf('session.list')).toHaveLength(1)
+    })
+    await vi.waitFor(() => {
+      expect(api.callsOf('subagent.list')).toEqual([
+        { parentSessionId: S1 },
+        { parentSessionId: S2 },
+      ])
+    })
+    expect(manager.get(S2).getSnapshot().subagent).toEqual({
+      address,
+      parentAvailable: true,
     })
     expect(manager.getListSnapshot().currentAddress).toEqual(address)
   })

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

@@ -4,20 +4,21 @@
  * isolation, and prompt failure mapping.
  */
 
+import { describe, expect, it, vi } from 'vitest'
 import { mkdtempSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
+import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
 import {
   PersistenceCoordinator,
@@ -25,7 +26,12 @@ import {
   type PersistenceBackend,
   type StoredPrefix,
 } from '@deepseek-ai/dsh-session-persistence'
-import { createSessionTestRemote } from './test-remote.ts'
+import { ApiSessionList } from '../src/list.ts'
+import {
+  createSessionTestRemote,
+  installSessionReadTestServices,
+  testSessionPersistence,
+} from './test-remote.ts'
 
 const sid = (id: string): SessionId => id as SessionId
 
@@ -47,8 +53,12 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
   return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
 }
 
+function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
+  return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
+}
+
 describe('sessions.list cold merge', () => {
-  it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
+  it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
@@ -64,8 +74,9 @@ describe('sessions.list cold merge', () => {
       header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
       header('vanished', 600),
       header('read-failure', 700),
+      { version: 0, id: sid('missing-cwd'), createdAt: 800 },
     ]
-    const readFrom = vi.fn(async (id: SessionId) => {
+    const inspect = vi.fn(async (id: SessionId) => {
       if (id === sid('small-blank')) {
         return {
           meta: metas[0]!,
@@ -88,7 +99,7 @@ describe('sessions.list cold merge', () => {
       if (id === sid('read-failure')) throw new Error('simulated read failure')
       throw new Error(`unexpected cold read: ${id}`)
     })
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve(metas),
       locate: (meta: SessionHeader) => {
         if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
@@ -96,8 +107,8 @@ describe('sessions.list cold merge', () => {
         if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
         return { kind: 'jsonl', path: smallPath }
       },
-      readFrom,
-    } as never)
+      inspect,
+    })
     ctx.provide('sessionProjectionCache', {
       cachedSnapshot: (meta: SessionHeader) => {
         if (meta.id === sid('small-blank')) {
@@ -111,6 +122,8 @@ describe('sessions.list cold merge', () => {
         }
         return undefined
       },
+      hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) =>
+        ctx.sessionProjections.hydrate(session, {}, events, 0),
     } as never)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
@@ -119,10 +132,8 @@ describe('sessions.list cold merge', () => {
     if (!response.ok) throw new Error('unreachable')
     const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
     expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
-    // A stale true hint cannot hide the turn found in the bounded read.
     expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
     expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
-    // false is monotonic, so this row skips stat/read and keeps cached recency.
     expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
     expect(byId['locationless']).toMatchObject({
       blank: false,
@@ -132,24 +143,25 @@ describe('sessions.list cold merge', () => {
     })
     expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
     expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
-    expect(readFrom).toHaveBeenCalledTimes(3)
-    expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
+    expect(byId['missing-cwd']).toBeUndefined()
+    expect(inspect).toHaveBeenCalledTimes(3)
+    expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
       sid('small-blank'),
       sid('small-conversation'),
       sid('read-failure'),
     ]))
   })
 
-  it('can disable bounded blank probes without hiding cold Sessions', async () => {
+  it('can disable bounded cold observations without hiding cold Sessions', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const meta = header('probe-disabled', 100)
-    const readFrom = vi.fn()
-    ctx.provide('sessionPersistence', {
+    const inspect = vi.fn()
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       locate: () => ({ kind: 'jsonl', path: '/not-read' }),
-      readFrom,
-    } as never)
+      inspect,
+    })
     const remote = createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
       cwd: '/tmp',
@@ -161,31 +173,23 @@ describe('sessions.list cold merge', () => {
     expect(response.value.items).toEqual([
       expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
     ])
-    expect(readFrom).not.toHaveBeenCalled()
+    expect(inspect).not.toHaveBeenCalled()
   })
 
-  it('replaces a probed cold row with the live Session that attached during the read', async () => {
+  it('prefers a live row attached during the query without folding its seed', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
-    const meta = header('attached-during-probe', 100)
-    const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
-    const path = join(root, 'small.log')
-    writeFileSync(path, 'x')
+    const meta = header('attached-during-list', 100)
     const started = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
-    ctx.provide('sessionPersistence', {
-      list: () => Promise.resolve([meta]),
-      locate: () => ({ kind: 'jsonl', path }),
-      readFrom: async () => {
+    providePersistence(ctx, {
+      list: async () => {
         started.resolve(undefined)
         await release.promise
-        return {
-          meta,
-          events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
-        }
+        return [meta]
       },
-    } as never)
+    })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
     const listing = remote.list(request({}))
@@ -214,10 +218,89 @@ describe('sessions.list cold merge', () => {
         sessionId: meta.id,
         blank: false,
         running: true,
-        updatedAt: 300,
+        updatedAt: 100,
       }),
     ])
   })
+
+  it('prefers a Session that attaches during its bounded cold observation', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
+    const path = join(root, 'small.log')
+    writeFileSync(path, 'small')
+    const meta = header('attached-during-probe', 100)
+    providePersistence(ctx, {
+      list: () => Promise.resolve([meta]),
+      locate: () => ({ kind: 'jsonl', path }),
+      inspect: () => {
+        const session = ctx.sessions.create(meta.id, {
+          meta,
+          seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }],
+        })
+        ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
+        return Promise.resolve({ meta, events: [] })
+      },
+    })
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
+    })
+
+    const response = await remote.list(request({}))
+    if (!response.ok) throw new Error('list failed')
+    expect(response.value.items).toEqual([
+      expect.objectContaining({ sessionId: meta.id, running: true, blank: false }),
+    ])
+    await ctx.fiber.dispose()
+  })
+
+  it('propagates a cold location failure instead of returning a partial list', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const meta = header('broken-cache', 100)
+    providePersistence(ctx, {
+      list: () => Promise.resolve([meta]),
+      locate: () => { throw new Error('location failed') },
+    })
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
+    })
+
+    await expect(remote.list(request({}))).resolves.toMatchObject({
+      ok: false,
+      error: { message: expect.stringContaining('location failed') as string },
+    })
+    await ctx.fiber.dispose()
+  })
+
+  it('supports an unsignalled probe whose observation has no projection registry', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    installSessionReadTestServices(ctx)
+    const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-'))
+    const path = join(root, 'small.log')
+    writeFileSync(path, 'small')
+    const meta = header('unprojected-small', 100)
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      locate: () => ({ kind: 'jsonl', path }),
+    } as never)
+    vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{
+      header: meta, live: false, persisted: true,
+    }])
+    vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({
+      source: 'prepared', header: meta, events: [], cursor: -1,
+      retain: vi.fn(), [Symbol.dispose]: vi.fn(),
+    })
+    const list = new ApiSessionList(ctx, 1024)
+
+    await expect(list.list()).resolves.toEqual([
+      expect.objectContaining({ sessionId: meta.id, blank: false }),
+    ])
+    await ctx.fiber.dispose()
+  })
 })
 
 describe('attached updatedAt tracks human prompts', () => {
@@ -226,6 +309,7 @@ describe('attached updatedAt tracks human prompts', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+    await new Promise(resolve => setTimeout(resolve, 0))
 
     // Old work, resumed just now: the log tail would report the pickup.
     const worked = 1_000_000
@@ -249,7 +333,7 @@ describe('attached updatedAt tracks human prompts', () => {
     const listed = await remote.list(request({}))
     if (!listed.ok) throw new Error('list failed')
     const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched')
-    expect(summary?.updatedAt).toBe(worked)
+    expect(summary?.updatedAt).toBe(500)
 
     // A lifecycle boundary is not a human update.
     resumed.append('turn/start', { turn: 2 })
@@ -291,11 +375,12 @@ describe('cold history recovery view', () => {
       list: () => Promise.resolve([structuredClone(meta)]),
     }
     const coordinator = new PersistenceCoordinator(ctx, backend)
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: (signal?: AbortSignal) => backend.list(signal),
       inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
+      borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal),
       locate: () => undefined,
-    } as never)
+    })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
     const history = await remote.page({
@@ -342,11 +427,11 @@ describe('Remote Agent and Session lookup policy', () => {
     const sessionId = sid('session-remote-cold')
     const meta = header(sessionId, 1000)
     const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect,
       locate: () => undefined,
-    } as never)
+    })
     const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
     const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
     const release = Promise.withResolvers<undefined>()
@@ -386,11 +471,11 @@ describe('Remote Agent and Session lookup policy', () => {
       origin: 'subagent',
     })
     const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([coldMeta]),
       inspect,
       locate: () => undefined,
-    } as never)
+    })
     const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
       meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
     })
@@ -442,7 +527,7 @@ describe('subagent ownership fence', () => {
         type: 'user/message',
         seq: 1,
         time: 2,
-        data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
+        data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
         surfaceOp: 'append',
       },
       {
@@ -458,15 +543,19 @@ describe('subagent ownership fence', () => {
       { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     const inspect = vi.fn(() => Promise.resolve({ meta, events }))
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect,
       locate: () => undefined,
-    } as never)
+    })
     const resume = vi.spyOn(ctx.agents, 'resume')
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+    ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
 
-    const history = await new SessionHistoryController(ctx).page({
+    const history = await new SessionHistoryController(
+      ctx,
+      (observation) => { observation[Symbol.dispose]() },
+    ).page({
       address: {
         kind: 'subagent',
         parentSessionId: meta.parentSession as SessionId,
@@ -516,11 +605,11 @@ describe('subagent ownership fence', () => {
         data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
       },
     ] as SessionEvent[]
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect: () => Promise.resolve({ meta, events }),
       locate: () => undefined,
-    } as never)
+    })
     // Stores whose headers predate `origin` classify a child only through the
     // descriptor event; the pre-release decision stops recognizing them, so
     // the ownership fence lets generic resume reach the registry instead of
@@ -583,9 +672,13 @@ describe('subagent ownership fence', () => {
     if (!queued.ok) expect(queued.error.code).toBe('agent-busy')
     expect(updateInbox).not.toHaveBeenCalled()
 
-    const models = await remote.models(request({ sessionId: startingChild.id }))
-    expect(models.ok).toBe(false)
-    if (!models.ok) expect(models.error.code).toBe('agent-busy')
+    const selection = await remote.selectModel(request({
+      sessionId: startingChild.id,
+      provider: 'p',
+      model: 'm',
+    }))
+    expect(selection.ok).toBe(false)
+    if (!selection.ok) expect(selection.error.code).toBe('agent-busy')
 
     const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
     expect(create.ok).toBe(false)
@@ -690,7 +783,7 @@ describe('subagent ownership fence', () => {
 })
 
 describe('degenerate composition (no persistence, no factory)', () => {
-  it('list skips the cold merge and history reports missing persistence as internal', async () => {
+  it('lists no cold rows and reports an absent point source as not found', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
@@ -707,20 +800,19 @@ describe('degenerate composition (no persistence, no factory)', () => {
     })
     expect(response.ok).toBe(false)
     if (!response.ok) {
-      expect(response.error.code).toBe('internal')
-      expect(response.error.message).toMatch(/session persistence is not configured/)
+      expect(response.error.code).toBe('session-not-found')
     }
   })
 
-  it('maps a persistence catalog miss to session-not-found without inspection', async () => {
+  it('maps a missing direct persistence read to session-not-found', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
     const inspect = vi.fn()
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([]),
       inspect,
-    } as never)
+    })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
     const response = await remote.page({
@@ -729,7 +821,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
     })
     expect(response.ok).toBe(false)
     if (!response.ok) expect(response.error.code).toBe('session-not-found')
-    expect(inspect).not.toHaveBeenCalled()
+    expect(inspect).toHaveBeenCalledOnce()
   })
 })
 
@@ -772,11 +864,11 @@ describe('sessions.prompt synchronous rejection', () => {
     await ctx.plugin(AgentRegistry)
     const sessionId = sid('race-resume')
     const meta: SessionHeader = header('race-resume', 1000)
-    ctx.provide('sessionPersistence', {
+    providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
       locate: () => undefined,
-    } as never)
+    })
     // The raced winner: a live parent-owned subagent publishes the identity
     // while the generic cold resume is in flight, so the resume collides.
     const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
@@ -794,10 +886,10 @@ describe('sessions.prompt synchronous rejection', () => {
     })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
-    const models = await remote.models(request({ sessionId }))
-    expect(models.ok).toBe(false)
-    if (!models.ok) {
-      expect(models.error).toMatchObject({
+    const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' }))
+    expect(selection.ok).toBe(false)
+    if (!selection.ok) {
+      expect(selection.error).toMatchObject({
         code: 'agent-busy',
         details: { reason: 'use subagent delivery for this child session' },
       })

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

@@ -10,7 +10,9 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
-import { createSessionTestRemote } from './test-remote.ts'
+import {
+  createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
+} from './test-remote.ts'
 
 const sid = (id: string): SessionId => id as SessionId
 
@@ -23,6 +25,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
   await ctx.plugin(SessionStore)
   await ctx.plugin(SystemPrompt, { persona: '' })
   await ctx.plugin(AgentRegistry)
+  installSessionReadTestServices(ctx)
   ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
   ctx.agents.setFactory({
     createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
@@ -116,18 +119,16 @@ describe('sessions.fork', () => {
       parentSession: child.id,
       origin: 'subagent',
     })
-    ctx.provide('sessionQuery', {
-      traceSession: vi.fn(() => Promise.resolve({
-        target: { header: grandchild.header, live: true, persisted: false },
-        ancestors: [
-          { header: child.header, live: true, persisted: false },
-          { header: owner.header, live: true, persisted: false },
-        ],
-        descendants: [],
-        complete: true,
-        root: { header: owner.header, live: true, persisted: false },
-      })),
-    } as never)
+    vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({
+      target: { header: grandchild.header, live: true, persisted: false },
+      ancestors: [
+        { header: child.header, live: true, persisted: false },
+        { header: owner.header, live: true, persisted: false },
+      ],
+      descendants: [],
+      complete: true,
+      root: { header: owner.header, live: true, persisted: false },
+    })
 
     const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
 
@@ -165,19 +166,10 @@ describe('sessions.fork', () => {
       },
       { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
-    ctx.provide('sessionPersistence', {
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
       list: () => Promise.resolve([header]),
       inspect: () => Promise.resolve({ meta: header, events }),
-    } as never)
-    ctx.provide('sessionQuery', {
-      traceSession: () => Promise.resolve({
-        target: { header, live: false, persisted: true },
-        ancestors: [],
-        descendants: [],
-        complete: true,
-        root: { header, live: false, persisted: true },
-      }),
-    } as never)
+    }) as never)
     const resume = vi.spyOn(ctx.agents, 'resume')
 
     const response = await remote(ctx).fork(request({ sessionId: sourceId }))

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

@@ -8,7 +8,7 @@ import { CallId, createMessage, createToolResultMessage, createUserMessage } fro
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import { createSessionTestRemote } from './test-remote.ts'
+import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
 
 /** Append a production-shaped human prompt to the session surface. */
 function appendUserText(session: Session, text: string): SessionEvent {
@@ -43,6 +43,7 @@ async function harness(): Promise<{ ctx: Context }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
+  installSessionReadTestServices(ctx)
   return { ctx }
 }
 
@@ -71,7 +72,7 @@ async function openFollow(
   }, signal)[Symbol.asyncIterator]()
   await expect(iterator.next()).resolves.toMatchObject({
     done: false,
-    value: { type: 'opened' },
+    value: { type: 'snapshot' },
   })
   return { [Symbol.asyncIterator]: () => iterator }
 }
@@ -79,8 +80,8 @@ async function openFollow(
 describe('Session history raw journal', () => {
   it('follows raw tool events and preserves result metadata without a Tools service', async () => {
     const { ctx } = await harness()
-    const session = ctx.sessions.create()
-    const history = new SessionHistoryController(ctx)
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
     const abort = new AbortController()
     const stream = await openFollow(history, session.id, abort.signal)
     const collected = collect(stream, 2, abort)
@@ -108,8 +109,8 @@ describe('Session history raw journal', () => {
 
   it('follows live results without rescanning Session history', async () => {
     const { ctx } = await harness()
-    const session = ctx.sessions.create()
-    const history = new SessionHistoryController(ctx)
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
     const abort = new AbortController()
     const stream = await openFollow(history, session.id, abort.signal)
     const iterator = stream[Symbol.asyncIterator]()
@@ -147,7 +148,7 @@ describe('Session history raw journal', () => {
   it('serves raw call and result entries without parsing tool arguments', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
-    const session = ctx.sessions.create()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
     const start = session.append('turn/start', { turn: 1 })
     const call = session.append('tool/call', {
       turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
@@ -178,7 +179,7 @@ describe('Session history raw journal', () => {
   it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
-    const session = ctx.sessions.create()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
     session.append('turn/start', { turn: 1 })
     const first = appendUserText(session, 'first prompt')
     appendAssistantText(session, 'first reply', 1)
@@ -227,7 +228,7 @@ describe('Session history raw journal', () => {
   it('paginates a message with many provenance sources without variadic argument expansion', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
-    const session = ctx.sessions.create()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
     session.append('turn/start', { turn: 1 })
     const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
       turn: 1,
@@ -265,8 +266,8 @@ describe('Session history raw journal', () => {
 
   it('follows a result after turn/end without reading the addressed Session log', async () => {
     const { ctx } = await harness()
-    const session = ctx.sessions.create()
-    const history = new SessionHistoryController(ctx)
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
     const abort = new AbortController()
     const stream = await openFollow(history, session.id, abort.signal)
     const iterator = stream[Symbol.asyncIterator]()

+ 34 - 26
packages/api/session-controller/tests/session-models.host.spec.ts

@@ -19,6 +19,7 @@ import type {
 import SessionStore from '@deepseek-ai/dsh-session'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
+import { buildModelCatalog } from '../src/catalog.ts'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import { createSessionTestRemote } from './test-remote.ts'
@@ -146,6 +147,14 @@ function registerTextOnly(ctx: Context): void {
   }('Text Only', []))
 }
 
+/** Resolve the Client-visible next selection from durable state and the Host default. */
+function currentSelection(ctx: Context, sessionId: SessionId) {
+  const session = ctx.sessions.get(sessionId)
+  if (session === undefined) throw new Error('expected a live test Session')
+  return ctx.sessionProjections.snapshot(session).values.modelSelection?.next
+    ?? ctx.agentDefaultModel.currentSelection()
+}
+
 describe('Web session model selection', () => {
   it('validates an ordered image batch before persisting any member', async () => {
     const { ctx, agent, sessionId } = await harness()
@@ -227,7 +236,7 @@ describe('Web session model selection', () => {
       type: 'image' as const,
       attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
     }
-    agent.session.append('user/message', {
+    const imageEvent = agent.session.append('user/message', {
       id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
     } as never, { surfaceOp: 'append' })
     expect(expectValue(await remote.selectModel(request({
@@ -238,8 +247,8 @@ describe('Web session model selection', () => {
       id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
       content: [{ type: 'text', text: 'image summarized' }],
     } as never, {
-      surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
-      sourceEventSeqs: agent.session.events.map(event => event.seq),
+      surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
+      sourceEventSeqs: [imageEvent.seq],
     })
     ;(agent.inbox.nextTurn as UserMessage[]).push({
       id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
@@ -290,10 +299,10 @@ describe('Web session model selection', () => {
       model: 'private-preview',
       reasoningEffort: ReasoningEffortId('max'),
     })
-    const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
+    createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
 
-    const catalog = expectValue(await remote.models(request({ sessionId })))
-    expect(catalog.current).toEqual({
+    const catalog = await buildModelCatalog(ctx)
+    expect(currentSelection(ctx, sessionId)).toEqual({
       provider: 'deepseek-official',
       model: 'private-preview',
       reasoningEffort: 'max',
@@ -324,7 +333,7 @@ describe('Web session model selection', () => {
   })
 
   it('preserves optional catalog metadata and string provider failures', async () => {
-    const { ctx, sessionId } = await harness()
+    const { ctx } = await harness()
     ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
       { provider: 'plain', id: 'plain-model', name: 'Plain Model' },
     ]))
@@ -339,12 +348,12 @@ describe('Web session model selection', () => {
         return Promise.reject('string catalog failure')
       }
     }('String Failure', []))
-    const remote = createSessionTestRemote(ctx, {
+    createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
       cwd: '/tmp',
     })
 
-    const catalog = expectValue(await remote.models(request({ sessionId })))
+    const catalog = await buildModelCatalog(ctx)
     expect(catalog.groups).toEqual(expect.arrayContaining([
       { id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
       {
@@ -371,10 +380,8 @@ describe('Web session model selection', () => {
     const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
     const signal = new AbortController().signal
 
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
-    expect((await ctx.systemPrompt.assemble()).variables)
-      .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
 
     const selected = expectValue(await remote.selectModel(request({
       sessionId,
@@ -389,7 +396,7 @@ describe('Web session model selection', () => {
     })
     await expect(agentEvents(ctx, agent).waterfall(
       'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
-    )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
+    )).resolves.toEqual(seed)
 
     expect((await ctx.systemPrompt.assemble()).variables)
       .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
@@ -440,7 +447,7 @@ describe('Web session model selection', () => {
         details: { provider: 'remote-rejected' },
       },
     })
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
     await ctx.fiber.dispose()
   })
@@ -448,18 +455,18 @@ describe('Web session model selection', () => {
   it('reads the Agent default live for a session whose log names no selection', async () => {
     const { ctx, sessionId } = await harness()
     let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
-    const remote = createSessionTestRemote(ctx, {
+    createSessionTestRemote(ctx, {
       defaultModelSelection: () => stored,
       cwd: '/tmp',
     })
 
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
     // The default moving after the session exists still reaches it: New
     // Session reuses a blank session rather than minting another, so a seed
     // captured at creation would show the superseded model there.
     stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
     await ctx.fiber.dispose()
   })
@@ -470,13 +477,13 @@ describe('Web session model selection', () => {
       model: 'deepseek-chat',
     })
     let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
-    const remote = createSessionTestRemote(ctx, {
+    createSessionTestRemote(ctx, {
       defaultModelSelection: () => stored,
       cwd: '/tmp',
     })
 
     stored = { provider: 'duplicate', model: 'same' }
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
     await ctx.fiber.dispose()
   })
@@ -512,7 +519,7 @@ describe('Web session model selection', () => {
       sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
     })))
     expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
-    expect(expectValue(await remote.models(request({ sessionId }))).current)
+    expect(currentSelection(ctx, sessionId))
       .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
     await ctx.fiber.dispose()
   })
@@ -533,15 +540,16 @@ describe('Web session model selection', () => {
       ok: false,
       error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
     })
-    expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false)
+    const unavailableCatalog = await buildModelCatalog(ctx)
+    expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
 
     // An advisory-unlisted model on a live route is NOT this: the route
     // serves it, so the prompt goes through and nothing blocks.
     expectValue(await remote.selectModel(request({
       sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
     })))
-    const catalog = expectValue(await remote.models(request({ sessionId })))
-    expect(catalog.routable).toBe(true)
+    const catalog = await buildModelCatalog(ctx)
+    expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true)
     expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
       .not.toContain('unlisted-but-served')
     await ctx.fiber.dispose()
@@ -549,18 +557,18 @@ describe('Web session model selection', () => {
 
   it('serves a session and its catalog when the stored default names a route that is gone', async () => {
     const { ctx, sessionId } = await harness()
-    const remote = createSessionTestRemote(ctx, {
+    createSessionTestRemote(ctx, {
       // What a Models-page removal leaves behind: the settings document still
       // names the route the user last picked, and nothing serves it.
       defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
       cwd: '/tmp',
     })
 
-    const catalog = expectValue(await remote.models(request({ sessionId })))
+    const catalog = await buildModelCatalog(ctx)
     // Passed through rather than repaired: matching no group is precisely what
     // makes the composer seat prompt for a selection instead of naming a model
     // the deployment cannot reach.
-    expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
+    expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
     expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
       .not.toContain('deleted-gateway/deleted-model')
     await ctx.fiber.dispose()

+ 5 - 3
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -6,7 +6,7 @@ import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
-import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
+import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { describe, expect, it } from 'vitest'
@@ -38,8 +38,9 @@ async function harness(presets?: readonly string[]) {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
-  ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
-  if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
+  if (presets !== undefined) {
+    ctx.provide('agentPresets', roster(presets) as never)
+  }
 
   const factory: AgentFactory = {
     async createAgent(_ownerCtx, options) {
@@ -63,6 +64,7 @@ async function harness(presets?: readonly string[]) {
     defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
     cwd,
   })
+  if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
   return { ctx, remote }
 }
 

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

@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
+import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -19,7 +20,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts'
-import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -48,6 +49,24 @@ function page(
   })
 }
 
+/** Read and close one snapshot-first follow generation. */
+async function opening(
+  remote: TestSessionRemote,
+  sessionId: SessionId,
+  maxMessages?: number,
+): Promise<Extract<SessionFollowFrame, { type: 'snapshot' }>> {
+  const abort = new AbortController()
+  const iterator = remote.follow({
+    address: { kind: 'session', sessionId },
+    ...(maxMessages === undefined ? {} : { maxMessages }),
+  }, abort.signal)[Symbol.asyncIterator]()
+  const first = await iterator.next()
+  abort.abort()
+  await iterator.return?.()
+  if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot')
+  return first.value
+}
+
 /** Whole-value unit folding the latest user/message text; null before the first. */
 type LastUserState = { text: string } | null
 const lastUserUnit = () => ({
@@ -77,7 +96,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
-  const session = ctx.sessions.create()
+  const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
   // The gateway reads both the session and durable inbox baseline.
   ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
   return { ctx, session }
@@ -96,33 +115,57 @@ function seedMessages(session: Session, count: number): void {
 const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
 describe('session.history projections block', () => {
+  it('tracks pending and used model selections across repeated request headers', async () => {
+    const { ctx, session } = await harness(true)
+    remote(ctx)
+    await new Promise(resolve => setTimeout(resolve, 0))
+    const selected = { provider: 'p', model: 'next' }
+    session.append('model/selection', selected)
+    session.append('model/selection', selected)
+    session.append('request/header', {
+      header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
+    })
+    session.append('request/header', {
+      header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
+    })
+
+    expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
+      lastUsed: { provider: 'p', model: 'used' },
+      next: selected,
+    })
+
+    session.append('request/header', {
+      header: { config: selected }, reason: 'initial',
+    })
+    expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
+      lastUsed: selected,
+      next: selected,
+    })
+  })
+
   it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())
     seedMessages(session, 3)
-    const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    expect(response.ok).toBe(true)
-    if (!response.ok) throw new Error('unreachable')
-    const { events, projections } = response.value
-    expect(projections).toBeDefined()
-    expect(projections?.asOfSeq).toBe(session.seq - 1)
-    expect(projections?.values['test/last-user']).toEqual({ text: 'm2' })
+    const snapshot = await opening(remote(ctx), session.id)
+    const { events, projections } = snapshot
+    expect(projections.asOfSeq).toBe(session.seq - 1)
+    expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
     // asOfSeq IS the window tail: the last served event carries it.
-    expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
+    expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq)
   })
 
-  it('cuts attached projections and events at the requested follow cursor', async () => {
+  it('returns a complete current replacement cut on each follow generation', async () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())
     seedMessages(session, 2)
 
-    const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: 0 }))
-    if (!response.ok) throw new Error('history failed')
+    const snapshot = await opening(remote(ctx), session.id)
 
-    expect(response.value.events.map(entry => entry.event.seq)).toEqual([0])
-    expect(response.value.projections?.asOfSeq).toBe(0)
-    expect(response.value.projections?.values).toEqual(
-      expect.objectContaining({ 'test/last-user': { text: 'm0' } }),
+    expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1])
+    expect(snapshot.projections.asOfSeq).toBe(1)
+    expect(snapshot.projections.values).toEqual(
+      expect.objectContaining({ 'test/last-user': { text: 'm1' } }),
     )
   })
 
@@ -130,12 +173,11 @@ describe('session.history projections block', () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())
 
-    const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: -1 }))
-    if (!response.ok) throw new Error('history failed')
+    const snapshot = await opening(remote(ctx), session.id)
 
-    expect(response.value.events).toEqual([])
-    expect(response.value.projections?.asOfSeq).toBe(-1)
-    expect(response.value.projections?.values).toEqual(
+    expect(snapshot.events).toEqual([])
+    expect(snapshot.projections.asOfSeq).toBe(-1)
+    expect(snapshot.projections.values).toEqual(
       expect.objectContaining({ 'test/last-user': null }),
     )
   })
@@ -157,10 +199,10 @@ describe('session.history projections block', () => {
       readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
     })
     const gateway = remote(ctx)
+    await new Promise(resolve => setTimeout(resolve, 0))
     seedMessages(session, 2)
-    const response = await page(gateway, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!response.ok) throw new Error('history failed')
-    expect(response.value.projections?.values['imageLimits']).toEqual(limits)
+    const snapshot = await opening(gateway, session.id)
+    expect(snapshot.projections.values['imageLimits']).toEqual(limits)
     // Constant unit: appending events must never broadcast an imageLimits projection.
     await new Promise(resolve => setTimeout(resolve, 0))
     const abort = new AbortController()
@@ -186,10 +228,8 @@ describe('session.history projections block', () => {
   it('leaves the imageLimits key absent while no attachment service is composed', async () => {
     const { ctx, session } = await harness(true)
     seedMessages(session, 1)
-    const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!response.ok) throw new Error('history failed')
-    expect(response.value.projections).toBeDefined()
-    expect('imageLimits' in (response.value.projections?.values ?? {})).toBe(false)
+    const snapshot = await opening(remote(ctx), session.id)
+    expect('imageLimits' in snapshot.projections.values).toBe(false)
   })
 
   it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
@@ -236,9 +276,8 @@ describe('session.history projections block', () => {
     abort.abort()
     await iterator.return?.()
 
-    const history = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!history.ok) throw new Error('history failed')
-    expect('test/internal-count' in (history.value.projections?.values ?? {})).toBe(false)
+    const history = await opening(proxy, session.id)
+    expect('test/internal-count' in history.projections.values).toBe(false)
     const listing = await proxy.list(request({}))
     if (!listing.ok) throw new Error('listing failed')
     const row = listing.value.items.find(item => item.sessionId === session.id)
@@ -250,18 +289,16 @@ describe('session.history projections block', () => {
     const dispose = ctx.sessionProjections.register(lastUserUnit())
     seedMessages(session, 1)
     const proxy = remote(ctx)
-    const before = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!before.ok) throw new Error('unreachable')
-    expect(before.value.projections?.values['test/last-user']).toEqual({ text: 'm0' })
+    const before = await opening(proxy, session.id)
+    expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' })
 
     dispose()
-    const after = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!after.ok) throw new Error('unreachable')
+    const after = await opening(proxy, session.id)
     // The registry stays mounted; only the disposed key leaves while the
     // gateway-owned Session-list unit remains.
-    expect(after.value.projections?.asOfSeq).toBe(session.seq - 1)
-    expect('test/last-user' in (after.value.projections?.values ?? {})).toBe(false)
-    expect(after.value.projections?.values.sessionListMetadata).toEqual({
+    expect(after.projections.asOfSeq).toBe(session.seq - 1)
+    expect('test/last-user' in after.projections.values).toBe(false)
+    expect(after.projections.values.sessionListMetadata).toEqual({
       blank: true,
       lastPromptAt: session.events.at(-1)?.time,
     })
@@ -284,7 +321,7 @@ describe('session.history projections block', () => {
 })
 
 describe('session.list projections column', () => {
-  it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
+  it('serves every already-materialized wire value from the live registry without folding', async () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())
     const gateway = remote(ctx)
@@ -302,6 +339,37 @@ describe('session.list projections column', () => {
     expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
   })
 
+  it('lists the latest preset selected by a blank Session instead of its creation preset', async () => {
+    const { ctx } = await harness(true)
+    const session = ctx.sessions.create(SessionId('preset-list'), {
+      meta: { cwd: '/workspace', agentPreset: 'standard' },
+    })
+    ctx.sessionProjections.register(agentPresetProjectionDefinition)
+    const gateway = remote(ctx)
+    await new Promise(resolve => setTimeout(resolve, 0))
+    session.append('agent-preset/selected', { agentPreset: 'minimal' })
+
+    const response = await gateway.list(request({}))
+    if (!response.ok) throw new Error('unreachable')
+    const row = response.value.items.find(item => item.sessionId === session.id)
+    expect(row?.projections?.values.agentPreset).toBe('minimal')
+  })
+
+  it('omits an unmaterialized live projection instead of folding history for listing', async () => {
+    const { ctx, session } = await harness(true)
+    seedMessages(session, 1)
+    const unit = lastUserUnit()
+    const apply = vi.fn(unit.apply)
+    ctx.sessionProjections.register({ ...unit, apply })
+
+    const response = await remote(ctx).list(request({}))
+    if (!response.ok) throw new Error('unreachable')
+    const row = response.value.items.find(item => item.sessionId === session.id)
+    expect(row).toBeDefined()
+    expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false)
+    expect(apply).not.toHaveBeenCalled()
+  })
+
   it('omits the column entirely when no registry is mounted', async () => {
     const { ctx, session } = await harness(false)
     seedMessages(session, 1)
@@ -312,7 +380,7 @@ describe('session.list projections column', () => {
     expect(row !== undefined && 'projections' in row).toBe(false)
   })
 
-  it('serves cold rows from the persisted projection cache with zero log loads', async () => {
+  it('serves every available cold projection hint from the cache with zero log loads', async () => {
     const { ctx } = await harness(true)
     const coldId = SessionId('session-cold-listing')
     const load = () => { throw new Error('list must not load event logs') }
@@ -327,14 +395,28 @@ describe('session.list projections column', () => {
       // The carrier hands the listed header through as the identity witness.
       cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
         (meta.id === coldId && meta.createdAt === 5
-          ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
+          ? {
+            asOfSeq: 7,
+            values: {
+              'test/last-user': { text: 'cached' },
+              sessionListMetadata: { blank: false, lastPromptAt: 6 },
+              title: 'Cached title',
+            },
+          }
           : undefined),
     } as never)
     const response = await remote(ctx).list(request({}))
     if (!response.ok) throw new Error('unreachable')
     const row = response.value.items.find(item => item.sessionId === coldId)
     expect(row?.running).toBe(false)
-    expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
+    expect(row?.projections).toEqual({
+      asOfSeq: 7,
+      values: {
+        'test/last-user': { text: 'cached' },
+        sessionListMetadata: { blank: false, lastPromptAt: 6 },
+        title: 'Cached title',
+      },
+    })
   })
 
   it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
@@ -421,9 +503,8 @@ describe('Session control projection frames', () => {
       { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
     ])
     // Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
-    const tail = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
-    if (!tail.ok) throw new Error('unreachable')
-    expect(tail.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq)
+    const tail = await opening(proxy, session.id)
+    expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq)
   })
 
   it('emits no projection frames when the composition has no registry', async () => {

+ 91 - 88
packages/api/session-controller/tests/session-search.host.spec.ts

@@ -6,22 +6,18 @@
 
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { stat } from 'node:fs/promises'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import {
+  SessionQueryEngine,
   SessionQueryError,
   type SessionSearchHit,
   type SessionSearchRequest,
 } from '@deepseek-ai/dsh-session-query'
 import { createSessionTestRemote } from './test-remote.ts'
-
-vi.mock('node:fs/promises', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('node:fs/promises')>()
-  return { ...actual, stat: vi.fn(actual.stat) }
-})
+import { ApiSessionList } from '../src/list.ts'
 
 const sid = (value: string): SessionId => value as SessionId
 const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
@@ -63,7 +59,48 @@ async function baseContext(): Promise<Context> {
   return ctx
 }
 
+/** Real query core with a programmable full-text provider for Host search tests. */
+class SearchSessionQuery extends SessionQueryEngine {
+  constructor(
+    ctx: Context,
+    private readonly search: (
+      ...args: Parameters<SessionQueryEngine['searchSessions']>
+    ) => Promise<unknown>,
+  ) {
+    super(ctx)
+  }
+
+  override searchSessions(
+    ...args: Parameters<SessionQueryEngine['searchSessions']>
+  ): ReturnType<SessionQueryEngine['searchSessions']> {
+    return this.search(...args) as ReturnType<SessionQueryEngine['searchSessions']>
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}
+
+function installSearchQuery(
+  ctx: Context,
+  searchSessions: (
+    ...args: Parameters<SessionQueryEngine['searchSessions']>
+  ) => Promise<unknown>,
+): void {
+  new SearchSessionQuery(ctx, searchSessions)
+}
+
 describe('session.search', () => {
+  it('rejects search when the query service is absent', async () => {
+    const ctx = await baseContext()
+    const list = new ApiSessionList(ctx, 0)
+
+    await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
+      failure: { code: 'internal' },
+    })
+    await ctx.fiber.dispose()
+  })
+
   it('searches only list-visible ids and current conversation-message events', async () => {
     const ctx = await baseContext()
     const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
@@ -111,7 +148,7 @@ describe('session.search', () => {
         },
       ],
     }))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
     const remote = createSessionTestRemote(ctx, defaults)
     const signal = new AbortController().signal
 
@@ -147,7 +184,7 @@ describe('session.search', () => {
     const ctx = await baseContext()
     ctx.sessions.create(sid('visible'), { meta: header('visible') })
     const searchSessions = vi.fn()
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
     const remote = createSessionTestRemote(ctx, defaults)
 
     for (const query of ['', '   ', 'contains\0nul', 'x'.repeat(501)]) {
@@ -161,7 +198,7 @@ describe('session.search', () => {
   it('returns an empty page without invoking the index when no session is visible', async () => {
     const ctx = await baseContext()
     const searchSessions = vi.fn()
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
     const remote = createSessionTestRemote(ctx, defaults)
 
     const response = await remote.search(
@@ -187,16 +224,14 @@ describe('session.search', () => {
       const base = hit('visible', index)
       return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
     }
-    ctx.provide('sessionQuery', {
-      searchSessions: () => Promise.resolve({
-        items: [
-          withBestMatch(0, { sessionId: sid('hidden') }),
-          withBestMatch(1, { surface: 'shadowed' }),
-          withBestMatch(2, { type: 'tool/result' }),
-          withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
-        ],
-      }),
-    } as never)
+    installSearchQuery(ctx, () => Promise.resolve({
+      items: [
+        withBestMatch(0, { sessionId: sid('hidden') }),
+        withBestMatch(1, { surface: 'shadowed' }),
+        withBestMatch(2, { type: 'tool/result' }),
+        withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
+      ],
+    }))
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('match'),
@@ -224,9 +259,7 @@ describe('session.search', () => {
         nextCursor: 'page-2',
       })
       .mockResolvedValueOnce({ items: items.slice(19) })
-    ctx.provide('sessionQuery', {
-      searchSessions,
-    } as never)
+    installSearchQuery(ctx, searchSessions)
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('match'),
       new AbortController().signal,
@@ -266,7 +299,7 @@ describe('session.search', () => {
         ...end < items.length ? { nextCursor: `offset-${end}` } : {},
       })
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('adaptive-page-limit'),
@@ -309,7 +342,7 @@ describe('session.search', () => {
         nextCursor: `page-${searchSessions.mock.calls.length}`,
       })
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('endless-pages'),
@@ -374,7 +407,7 @@ describe('session.search', () => {
           return Promise.reject(new Error('unexpected provider call'))
       }
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('stale-restart'),
@@ -413,7 +446,7 @@ describe('session.search', () => {
         nextCursor: `cursor-${searchSessions.mock.calls.length}`,
       })
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('stale-churn'),
@@ -442,7 +475,7 @@ describe('session.search', () => {
         controller.abort()
         return Promise.reject(stale)
       })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('abort-stale'),
@@ -463,7 +496,7 @@ describe('session.search', () => {
       'provider generation changed before paging',
       'SESSION_QUERY_STALE_CURSOR',
     )))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('first-page-stale'),
@@ -487,7 +520,7 @@ describe('session.search', () => {
         'continuation limit is invalid',
         'SESSION_QUERY_INVALID_LIMIT',
       ))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('continuation-invalid-limit'),
@@ -514,7 +547,7 @@ describe('session.search', () => {
         'SESSION_QUERY_INVALID_LIMIT',
       ),
     ))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('minimum-page-limit'),
@@ -540,7 +573,7 @@ describe('session.search', () => {
         'SESSION_QUERY_INVALID_LIMIT',
       ))
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('abort-invalid-limit'),
@@ -559,7 +592,7 @@ describe('session.search', () => {
     ctx.sessions.create(sid('visible'), { meta: header('visible') })
     const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
     const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('oversized-page'),
@@ -585,7 +618,7 @@ describe('session.search', () => {
       }
       return Promise.resolve({ items: oversized })
     })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('adapted-oversized-page'),
@@ -611,9 +644,7 @@ describe('session.search', () => {
         snippet: `${expected}${'y'.repeat(10_000)}`,
       },
     }
-    ctx.provide('sessionQuery', {
-      searchSessions: () => Promise.resolve({ items: [overlong] }),
-    } as never)
+    installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] }))
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('bounded-snippet'),
@@ -635,7 +666,7 @@ describe('session.search', () => {
     const searchSessions = vi.fn()
       .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
       .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('repeated-cursor'),
@@ -658,7 +689,7 @@ describe('session.search', () => {
     const searchSessions = vi.fn()
       .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
       .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('repeated-lookahead-cursor'),
@@ -685,7 +716,7 @@ describe('session.search', () => {
       .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
       .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
       .mockResolvedValueOnce({ items: items.slice(20) })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('duplicate-pages'),
@@ -713,7 +744,7 @@ describe('session.search', () => {
         controller.abort()
         return Promise.resolve({ items: [] })
       })
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('cancel-continuation'),
@@ -743,7 +774,7 @@ describe('session.search', () => {
     const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
       items: [hit('cold-32750')],
     }))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('large corpus'),
@@ -761,12 +792,13 @@ describe('session.search', () => {
     expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
   })
 
-  it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
+  it('propagates cancellation through the lightweight visibility listing', async () => {
     const ctx = await baseContext()
     const controller = new AbortController()
     const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
     const list = vi.fn((signal?: AbortSignal) => {
       expect(signal).toBe(controller.signal)
+      controller.abort()
       return Promise.resolve(cold)
     })
     let locateCalls = 0
@@ -774,12 +806,11 @@ describe('session.search', () => {
       list,
       locate: () => {
         locateCalls++
-        controller.abort()
         return undefined
       },
     } as never)
     const searchSessions = vi.fn()
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
 
     const response = await createSessionTestRemote(ctx, defaults).search(
       request('cancel-during-visibility'),
@@ -791,53 +822,34 @@ describe('session.search', () => {
       error: { code: 'cancelled' },
     })
     expect(list).toHaveBeenCalledOnce()
-    expect(locateCalls).toBe(1)
+    expect(locateCalls).toBe(0)
     expect(searchSessions).not.toHaveBeenCalled()
   })
 
-  it('awaits every started cold-summary stat before returning cancellation', async () => {
+  it('does not stat or locate cold artifacts while collecting search visibility', async () => {
     const ctx = await baseContext()
-    const controller = new AbortController()
     const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
-    const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
-    const statMock = vi.mocked(stat)
-    statMock.mockClear()
-    for (const gate of statGates) {
-      statMock.mockImplementationOnce((() => gate.promise) as never)
-    }
+    const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }))
     ctx.provide('sessionPersistence', {
       list: () => Promise.resolve(cold),
-      locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
+      locate,
     } as never)
-    const searchSessions = vi.fn()
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    const searchSessions = vi.fn(() => Promise.resolve({ items: [] }))
+    installSearchQuery(ctx, searchSessions)
 
-    let settled = false
-    const responsePromise = createSessionTestRemote(ctx, defaults).search(
-      request('cancel-during-cold-stats'),
-      controller.signal,
-    ).finally(() => {
-      settled = true
-    })
-    await vi.waitFor(() => {
-      expect(statMock).toHaveBeenCalledTimes(16)
-    })
-
-    controller.abort()
-    statGates[0]!.resolve({ mtimeMs: 101 })
-    await new Promise<void>(resolve => setImmediate(resolve))
-    expect(settled).toBe(false)
-
-    for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
-    const response = await responsePromise
+    const response = await createSessionTestRemote(ctx, defaults).search(
+      request('header-only-visibility'),
+      new AbortController().signal,
+    )
     expect(response).toMatchObject({
-      ok: false,
-      error: { code: 'cancelled' },
+      ok: true,
+      value: { items: [], hasMore: false },
     })
-    expect(searchSessions).not.toHaveBeenCalled()
+    expect(locate).not.toHaveBeenCalled()
+    expect(searchSessions).toHaveBeenCalledOnce()
   })
 
-  it('maps missing composition, query cancellation, and provider failure', async () => {
+  it('maps preflight cancellation, query cancellation, and provider failure', async () => {
     const missingCtx = await baseContext()
     missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
     const missingApi = createSessionTestRemote(missingCtx, defaults)
@@ -852,22 +864,13 @@ describe('session.search', () => {
       error: { code: 'cancelled' },
     })
 
-    const missing = await missingApi.search(
-      request('needle'),
-      new AbortController().signal,
-    )
-    expect(missing.ok).toBe(false)
-    if (missing.ok) throw new Error('unreachable')
-    expect(missing.error.code).toBe('internal')
-    expect(missing.error.message).toContain('does not mount')
-
     const ctx = await baseContext()
     ctx.sessions.create(sid('visible'), { meta: header('visible') })
     const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
     const searchSessions = vi.fn()
       .mockRejectedValueOnce(aborted)
       .mockRejectedValueOnce(new Error('database unavailable'))
-    ctx.provide('sessionQuery', { searchSessions } as never)
+    installSearchQuery(ctx, searchSessions)
     const remote = createSessionTestRemote(ctx, defaults)
 
     const cancelled = await remote.search(

+ 41 - 19
packages/api/session-controller/tests/session.client.spec.ts

@@ -69,11 +69,12 @@ describe('Session open', () => {
     expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' })
   })
 
-  it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
+  it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => {
     const { api, session } = makeSession()
     await Promise.all([session.open(), session.open()])
     await session.open()
-    expect(api.callsOf('session.history')).toHaveLength(1)
+    expect(api.callsOf('session.follow')).toHaveLength(1)
+    expect(api.callsOf('session.history')).toEqual([])
   })
 
   it('lands an error result in openState=error with the RpcError kept', async () => {
@@ -98,7 +99,7 @@ describe('Session open', () => {
     const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
     api.onHistory = () => gate.promise
     const opening = session.open()
-    // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
+    // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail.
     const page = plainTurn(10, 0, '早', '安')
     const deliveries = [
       follow(api, ev.turnStart(15, 1)),
@@ -151,7 +152,7 @@ describe('live event path', () => {
     // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
     await follow(api, ev.assistant(9, 1, 'd'))
     await vi.waitFor(() => {
-      expect(api.callsOf('session.history').length).toBe(2)
+      expect(api.callsOf('session.history')).toHaveLength(1)
     })
     await vi.waitFor(() => {
       expect(eventSeqs(session)).toEqual(
@@ -172,8 +173,8 @@ describe('paging', () => {
     await session.open()
     await session.loadOlder()
     const snapshot = session.getSnapshot()
+    expect(api.callsOf('session.follow')).toHaveLength(1)
     expect(api.callsOf('session.history')).toMatchObject([
-      { sessionId: SID, throughSeq: 11 },
       { sessionId: SID, throughSeq: 11, beforeSeq: 6 },
     ])
     expect(snapshot.hasMore).toBe(false)
@@ -231,7 +232,8 @@ describe('paging', () => {
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
     }))
     await Promise.all([first, second])
-    expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
+    expect(api.callsOf('session.follow')).toHaveLength(1)
+    expect(api.callsOf('session.history')).toHaveLength(1)
   })
 })
 
@@ -248,9 +250,15 @@ describe('prompt and cancel errors', () => {
 
     expect(prompted).toEqual({ ok: true, value: { accepted: true } })
     expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
-    expect(api.callsOf('subagent.history')).toEqual([
-      { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
+    expect(api.callsOf('session.follow')).toEqual([
+      {
+        address: {
+          kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
+        },
+        maxMessages: 50,
+      },
     ])
+    expect(api.callsOf('subagent.history')).toEqual([])
     expect(api.callsOf('subagent.prompt')).toEqual([
       {
         parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
@@ -300,9 +308,15 @@ describe('prompt and cancel errors', () => {
 
     expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
     expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
-    expect(api.callsOf('subagent.history')).toEqual([
-      { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', throughSeq: -1, maxMessages: 50 },
+    expect(api.callsOf('session.follow')).toEqual([
+      {
+        address: {
+          kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot',
+        },
+        maxMessages: 50,
+      },
     ])
+    expect(api.callsOf('subagent.history')).toEqual([])
     expect(api.callsOf('subagent.prompt')).toEqual([])
     expect(api.callsOf('subagent.interrupt')).toEqual([])
     expect(api.callsOf('session.cancel')).toEqual([])
@@ -572,7 +586,7 @@ describe('remaining branches', () => {
     const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
     api.onHistory = () => repairPull.promise
     const delivery = follow(api, ev.user(9, '洞'))
-    await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
+    await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) })
     api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
     const resynced = session.resync() // bumps the generation
     repairPull.resolve(ok({
@@ -626,7 +640,7 @@ describe('remaining branches', () => {
 })
 
 describe('resync', () => {
-  it('keeps the old feed until one sorted page-and-live replacement is ready', async () => {
+  it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗'))
     await session.open()
@@ -640,11 +654,16 @@ describe('resync', () => {
     })
 
     const syncing = session.resync()
-    await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
+    await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) })
     expect(session.eventSource.getSnapshot()).toBe(oldWindow)
     expect(publications).toEqual([])
 
-    await Promise.all([
+    api.onHistory = () => histResponse([
+      ...plainTurn(10, 2, '终', '页'),
+      ev.user(16, '后到低位'),
+      ev.user(17, '后到高位'),
+    ])
+    const liveDeliveries = Promise.all([
       follow(api, ev.user(17, '后到高位')),
       follow(api, ev.user(16, '后到低位')),
     ])
@@ -654,12 +673,15 @@ describe('resync', () => {
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
     }))
-    await syncing
+    await Promise.all([syncing, liveDeliveries])
+    await vi.waitFor(() => {
+      expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
+    })
 
-    expect(publications).toHaveLength(1)
-    expect(publications[0]?.entries).not.toHaveLength(0)
-    expect(publications[0]?.change.kind).toBe('replace')
-    expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
+    expect(publications).toHaveLength(2)
+    expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace'])
+    expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15])
+    expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
     off()
   })
 

+ 44 - 25
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -45,7 +45,7 @@ type FeedRow = {
   origin?: 'subagent'
   running?: boolean
   blank?: boolean
-  agentPreset?: string
+  projections?: Record<string, unknown>
 }
 
 async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -55,7 +55,9 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
       ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
       ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
       ...(r.origin !== undefined ? { origin: r.origin } : {}),
-      ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
+      ...(r.projections === undefined
+        ? {}
+        : { projections: { asOfSeq: 0, values: r.projections } }),
     })),
   }) as never)
   await b.svc.refresh()
@@ -81,19 +83,17 @@ describe('list store projection', () => {
     expect(state.byId[sid('s2')]?.title).toBeUndefined()
   })
 
-  it('reprojects a blank session whose composition switched and nothing else moved', async () => {
+  it('reprojects a blank session from the generic agent-preset projection', async () => {
     const b = bench()
-    await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
-    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
+    await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }])
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard')
 
-    // A confirmed switch moves the preset alone: the row keeps its updatedAt,
-    // title, running, and blank bits, so an identity guard blind to the preset
-    // would serve the old row forever — and every reader (the hero chip's own
-    // no-op check, the header label) would keep the composition it replaced.
-    b.svc.noteAgentPreset(sid('s1'), 'minimal')
+    b.svc.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1,
+    })
     await Promise.resolve()
 
-    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal')
   })
 
   it('reflects live increments (host stream via manager) into the store', async () => {
@@ -214,6 +214,7 @@ describe('scope tree', () => {
     b.svc.open(sid('s2'))
 
     await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
+    notified.mockClear()
     await b.api.pushFollow(sid('s1'), {
       type: 'event',
       event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
@@ -252,7 +253,7 @@ describe('Agent scope disposal lifecycle', () => {
       ...remote,
       session: {
         ...remote.session,
-        follow: (_request, signal) => {
+        follow: (request, signal) => {
           if (signal === undefined) throw new Error('fixture requires a signal')
           followSignal = signal
           let opened = false
@@ -263,7 +264,20 @@ describe('Agent scope disposal lifecycle', () => {
                   opened = true
                   return Promise.resolve({
                     done: false,
-                    value: { type: 'opened', cursor: -1 } as const,
+                    value: {
+                      type: 'snapshot',
+                      header: {
+                        version: 0,
+                        id: request.address.kind === 'session'
+                          ? request.address.sessionId
+                          : request.address.childSessionId,
+                        createdAt: 0,
+                      },
+                      cursor: -1,
+                      events: [],
+                      hasMore: false,
+                      projections: { asOfSeq: -1, values: {} },
+                    } as const,
                   })
                 }
                 return new Promise((_resolve, reject) => {
@@ -325,7 +339,14 @@ describe('Agent scope disposal lifecycle', () => {
                   opened = true
                   return Promise.resolve({
                     done: false,
-                    value: { type: 'opened', cursor: -1 } as const,
+                    value: {
+                      type: 'snapshot',
+                      header: { version: 0, id: sessionId, createdAt: 0 },
+                      cursor: -1,
+                      events: [],
+                      hasMore: false,
+                      projections: { asOfSeq: -1, values: {} },
+                    } as const,
                   })
                 }
                 return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
@@ -457,22 +478,22 @@ describe('binding and stage lifecycle', () => {
   it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
+    const followStarts = () => b.api.followStarts.map(String)
     // Resolution is addressing, not staging: no window pull.
     b.svc.scope(sid('s1'))
     b.svc.binding(sid('s1'))
-    expect(historyCalls()).toHaveLength(0)
+    expect(followStarts()).toEqual([])
     b.svc.open(sid('s1'))
     await vi.waitFor(() => {
-      expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
+      expect(followStarts()).toEqual(['s1'])
     })
     // Same current again: no second pull.
     b.svc.open(sid('s1'))
-    expect(historyCalls()).toHaveLength(1)
+    expect(followStarts()).toHaveLength(1)
     // Stage moves: the new occupant opens.
     b.svc.open(sid('s2'))
     await vi.waitFor(() => {
-      expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
+      expect(followStarts()).toEqual(['s1', 's2'])
     })
   })
 
@@ -486,11 +507,10 @@ describe('binding and stage lifecycle', () => {
     })
     try {
       const b = bench()
-      expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
+      expect(b.api.followStarts).toEqual([])
       await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
       await vi.waitFor(() => {
-        const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
-        expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
+        expect(b.api.followStarts.map(String)).toEqual(['s1'])
       })
     } finally {
       vi.unstubAllGlobals()
@@ -827,13 +847,12 @@ describe('coverage tails (branch duals)', () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     b.svc.open(sid('s1'))
-    const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
-    await vi.waitFor(() => { expect(historyCalls()).toHaveLength(1) })
+    await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) })
     await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
     expect(b.svc.scope(sid('s1'))).toBeDefined()
     // Resurfacing re-projects current = s1: same stage occupant, no second pull.
     await feedList(b, [{ id: 's1' }])
-    expect(historyCalls()).toHaveLength(1)
+    expect(b.api.followStarts).toHaveLength(1)
     expect(b.svc.list.getSnapshot().current).toBe('s1')
   })
 

+ 82 - 4
packages/api/session-controller/tests/test-remote.ts

@@ -2,6 +2,16 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import {
+  SessionPersistenceCorruptionError,
+  SessionPersistenceNotFoundError,
+  SessionPersistenceRevision,
+  type BorrowedSessionSource,
+  type SessionInspection,
+} from '@deepseek-ai/dsh-session-persistence'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
 import { vi } from 'vitest'
 import {
   TypertRemoteFailure,
@@ -18,10 +28,10 @@ import type {
   SessionCreateValue,
   SessionForkRequest,
   SessionForkValue,
+  SessionFollowFrame,
+  SessionFollowRequest,
   SessionListRequest,
   SessionListValue,
-  SessionModels,
-  SessionModelsRequest,
   SessionPage,
   SessionPageRequest,
   SessionPromptRequest,
@@ -41,7 +51,6 @@ export interface TestSessionRemote {
   list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
   search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
   create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
-  models(request: SessionModelsRequest): Promise<RemoteResult<SessionModels>>
   selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
   rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
   fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
@@ -50,6 +59,7 @@ export interface TestSessionRemote {
   updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
   cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
   page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
+  follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
   control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
 }
 
@@ -63,6 +73,73 @@ export interface TestSessionRemoteDefaults {
 
 const installed = new WeakMap<Context, SessionController>()
 
+type LegacyTestPersistence = Record<string, unknown> & {
+  readonly inspect?: (
+    sessionId: SessionId,
+    signal?: AbortSignal,
+  ) => Promise<SessionInspection | undefined>
+  readonly borrowSession?: (
+    sessionId: SessionId,
+    signal?: AbortSignal,
+  ) => Promise<BorrowedSessionSource>
+}
+
+/** Add the preparation-backed point-read contract to compact persistence doubles. */
+export function testSessionPersistence(
+  ctx: Context,
+  persistence: LegacyTestPersistence,
+): LegacyTestPersistence {
+  if (persistence.borrowSession !== undefined) return persistence
+  return {
+    ...persistence,
+    borrowSession: async (sessionId, signal) => {
+      signal?.throwIfAborted()
+      const inspection = await persistence.inspect?.(sessionId, signal)
+      signal?.throwIfAborted()
+      if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
+      try {
+        const preparedSession = ctx.sessions.prepare(inspection.meta.id, {
+          seed: [...inspection.events],
+          meta: inspection.meta,
+          seedSource: 'persistence',
+        })
+        return {
+          source: 'prepared',
+          inspection: {
+            meta: preparedSession.header,
+            events: Object.freeze([...inspection.events]),
+          },
+          revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`),
+          preparedSession,
+          [Symbol.dispose]: () => {},
+        }
+      } catch (error: unknown) {
+        throw new SessionPersistenceCorruptionError(
+          `test session "${sessionId}" failed validation: ${String(error)}`,
+          { cause: error },
+        )
+      }
+    },
+  }
+}
+
+/** Concrete point-read query used by Session Controller tests that do not exercise search. */
+class TestSessionQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is not configured in this test'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}
+
+/** Install the required projection and point-query services for direct controller tests. */
+export function installSessionReadTestServices(ctx: Context): void {
+  if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
+  if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
+}
+
 function installControllers(
   ctx: Context,
   defaults: TestSessionRemoteDefaults,
@@ -93,6 +170,7 @@ function installControllers(
       },
     } as never)
   }
+  installSessionReadTestServices(ctx)
   const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
   let controller: SessionController
   try {
@@ -151,7 +229,6 @@ export function createSessionTestRemote(
       signal,
     ),
     create: request => remoteResult(() => direct.create(request)),
-    models: request => remoteResult(() => direct.models(request)),
     selectModel: request => remoteResult(() => direct.selectModel(request)),
     rename: request => remoteResult(() => direct.rename(request)),
     fork: request => remoteResult(() => direct.fork(request)),
@@ -166,6 +243,7 @@ export function createSessionTestRemote(
       () => direct.page(request, signal),
       signal,
     ),
+    follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
     control: (signal = new AbortController().signal) => direct.control(signal),
   }
 }

+ 58 - 32
packages/api/session-controller/tests/transport.client.spec.ts

@@ -43,6 +43,25 @@ function page(events: readonly SessionEventEntry[], hasMore = false): SessionPag
   return { events, hasMore }
 }
 
+function snapshot(
+  cursor: number,
+  events: readonly SessionEventEntry[],
+  hasMore = false,
+): SessionFollowFrame {
+  return {
+    type: 'snapshot',
+    header: {
+      version: 0,
+      id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
+      createdAt: 0,
+    },
+    cursor,
+    events,
+    hasMore,
+    projections: { asOfSeq: cursor, values: {} },
+  }
+}
+
 function sessionClient(remote: SessionTransportRemote) {
   return {
     session: remote as SessionRemote,
@@ -108,14 +127,13 @@ describe('Session Client stream adapters', () => {
     const remote = new ScriptedSessionRemote(
       [{
         frames: [
-          { type: 'opened', cursor: 3 },
+          snapshot(3, [entry(2), entry(3)], true),
           { type: 'event', ...entry(3) },
           { type: 'event', ...entry(4) },
         ],
         hold: true,
       }],
       [
-        { ok: true, value: page([entry(2), entry(3)], true) },
         { ok: true, value: page([entry(0), entry(1)], false) },
       ],
     )
@@ -129,9 +147,8 @@ describe('Session Client stream adapters', () => {
     await vi.waitFor(() => { expect(changes).toHaveLength(2) })
     await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
 
-    expect(remote.followRequests).toEqual([{ address: ADDRESS }])
+    expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }])
     expect(remote.pageRequests).toEqual([
-      { address: ADDRESS, throughSeq: 3, maxMessages: 50 },
       { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
     ])
     expect(changes).toMatchObject([
@@ -143,20 +160,17 @@ describe('Session Client stream adapters', () => {
     expect(remote.signals[0]?.aborted).toBe(true)
   })
 
-  it('resumes after the applied cursor and repairs through the addressed tail page', async () => {
+  it('replaces the retained window from each reconnect snapshot', async () => {
     const lost = new RemoteStreamCarrierError('lost')
     const remote = new ScriptedSessionRemote(
       [
         {
-          frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }],
+          frames: [snapshot(1, [entry(0), entry(1)]), { type: 'event', ...entry(2) }],
           terminal: lost,
         },
-        { frames: [{ type: 'opened', cursor: 4 }], hold: true },
-      ],
-      [
-        { ok: true, value: page([entry(0), entry(1)]) },
-        { ok: true, value: page([entry(0), entry(1), entry(2), entry(3), entry(4)]) },
+        { frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
       ],
+      [],
     )
     const changes: SessionJournalChange[] = []
     const carrierFailed = vi.fn()
@@ -170,13 +184,10 @@ describe('Session Client stream adapters', () => {
     await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
 
     expect(remote.followRequests).toEqual([
-      { address: ADDRESS },
-      { address: ADDRESS, afterSeq: 2 },
-    ])
-    expect(remote.pageRequests).toEqual([
-      { address: ADDRESS, throughSeq: 1, maxMessages: 50 },
-      { address: ADDRESS, throughSeq: 4, maxMessages: 50 },
+      { address: ADDRESS, maxMessages: 50 },
+      { address: ADDRESS, maxMessages: 50 },
     ])
+    expect(remote.pageRequests).toEqual([])
     expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
     expect(carrierFailed).toHaveBeenCalledWith(lost)
     await stream.dispose()
@@ -187,16 +198,13 @@ describe('Session Client stream adapters', () => {
     const remote = new ScriptedSessionRemote(
       [
         {
-          frames: [{ type: 'opened', cursor: 0 }],
+          frames: [snapshot(0, [entry(0)])],
           waitAfterFrames: finish.promise,
           terminal: new RemoteStreamCarrierError('lost'),
         },
-        { frames: [{ type: 'opened', cursor: 1 }], hold: true },
-      ],
-      [
-        { ok: true, value: page([entry(0)]) },
-        { ok: true, value: page([entry(0), entry(1)]) },
+        { frames: [snapshot(1, [entry(0), entry(1)])], hold: true },
       ],
+      [],
     )
     const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
       publish: vi.fn(),
@@ -205,18 +213,33 @@ describe('Session Client stream adapters', () => {
 
     await stream.open({})
     finish.resolve(undefined)
-    await vi.waitFor(() => { expect(remote.pageRequests).toHaveLength(2) })
-    expect(remote.pageRequests).toEqual([
-      { address: ADDRESS, throughSeq: 0 },
-      { address: ADDRESS, throughSeq: 1 },
-    ])
+    await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
+    expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }])
+    expect(remote.pageRequests).toEqual([])
+    await stream.dispose()
+  })
+
+  it('repairs a live gap without adding an absent message limit', async () => {
+    const remote = new ScriptedSessionRemote(
+      [{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }],
+      [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
+    )
+    const changes: SessionJournalChange[] = []
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: (change) => { changes.push(change) },
+      failed: vi.fn(),
+    })
+
+    await stream.open({})
+    await vi.waitFor(() => { expect(changes).toHaveLength(2) })
+    expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }])
     await stream.dispose()
   })
 
-  it('turns a page failure into a typed stream failure and closes follow', async () => {
+  it('turns a pagination failure into a typed stream failure', async () => {
     const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const
     const remote = new ScriptedSessionRemote(
-      [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
+      [{ frames: [snapshot(-1, [])], hold: true }],
       [{ ok: false, error: failure }],
     )
     const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
@@ -224,13 +247,16 @@ describe('Session Client stream adapters', () => {
       failed: vi.fn(),
     })
 
-    await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError)
+    await stream.open({})
+    await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError)
     await expect(stream.open({})).rejects.toThrow('already opened')
     expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details)))
       .toEqual(failure)
     expect(sessionStreamFailure(new Error('local'))).toBeUndefined()
-    expect(remote.signals[0]?.aborted).toBe(true)
+    expect(remote.signals[0]?.aborted).toBe(false)
     expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
+    await stream.dispose()
+    expect(remote.signals[0]?.aborted).toBe(true)
   })
 
   it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {

+ 212 - 93
packages/api/session-controller/tests/transport.host.spec.ts

@@ -2,9 +2,12 @@ import { Context } from '@deepseek-ai/cordis'
 import { createScope } from '@deepseek-ai/dsh-scope'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import { describe, expect, it, vi } from 'vitest'
 import { SessionHistoryController } from '../src/history.ts'
+import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 const signal = (): AbortSignal => new AbortController().signal
 
@@ -22,7 +25,13 @@ function append(
 }
 
 function event(type: string, seq: number, data: unknown = {}): SessionEvent {
-  return { type, seq, time: seq + 1, data } as SessionEvent
+  return {
+    type,
+    seq,
+    time: seq + 1,
+    data,
+    ...type.startsWith('fixture/') ? { ignorable: true } : {},
+  } as SessionEvent
 }
 
 function cold(
@@ -30,10 +39,10 @@ function cold(
   header: SessionHeader,
   events: readonly SessionEvent[],
 ): void {
-  ctx.provide('sessionPersistence', {
+  ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
     list: () => Promise.resolve([header]),
     inspect: () => Promise.resolve({ meta: header, events }),
-  } as never)
+  }) as never)
 }
 
 interface Deferred<T> {
@@ -50,7 +59,9 @@ function deferred<T>(): Deferred<T> {
 async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
-  const transport = new SessionHistoryController(ctx)
+  installSessionReadTestServices(ctx)
+  ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+  const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
   return { ctx, transport }
 }
 
@@ -65,7 +76,7 @@ describe('SessionHistoryController', () => {
       abort.signal,
     )[Symbol.asyncIterator]()
 
-    expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } })
+    expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     expect(await iterator.next()).toMatchObject({
       done: false,
@@ -85,10 +96,13 @@ describe('SessionHistoryController', () => {
   it('ends active followers when the owning Controller unloads', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
+    installSessionReadTestServices(ctx)
     let transport!: SessionHistoryController
     const owner = ctx.plugin(Object.assign(
-      (inner: Context) => { transport = new SessionHistoryController(inner) },
-      { inject: ['sessions'] },
+      (inner: Context) => {
+        transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
+      },
+      { inject: ['sessions', 'sessionQuery'] },
     ))
     await owner.await()
     const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } })
@@ -97,9 +111,9 @@ describe('SessionHistoryController', () => {
       new AbortController().signal,
     )[Symbol.asyncIterator]()
 
-    await expect(iterator.next()).resolves.toEqual({
+    await expect(iterator.next()).resolves.toMatchObject({
       done: false,
-      value: { type: 'opened', cursor: -1 },
+      value: { type: 'snapshot', cursor: -1 },
     })
     const pending = iterator.next()
     await owner.dispose()
@@ -107,7 +121,7 @@ describe('SessionHistoryController', () => {
     await ctx.fiber.dispose()
   })
 
-  it('resumes from the last applied seq before delivering later live events', async () => {
+  it('reconnects with a complete replacement snapshot before later live events', async () => {
     const { ctx, transport } = await setup()
     const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } })
     session.append('turn/start', { turn: 1 })
@@ -116,12 +130,16 @@ describe('SessionHistoryController', () => {
     const abort = new AbortController()
     const iterator = transport.follow({
       address: { kind: 'session', sessionId: session.id },
-      afterSeq: 0,
     }, abort.signal)[Symbol.asyncIterator]()
 
-    expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
-    expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } })
-    expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } })
+    expect(await iterator.next()).toMatchObject({
+      done: false,
+      value: {
+        type: 'snapshot',
+        cursor: 2,
+        events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { event: { seq: 2 } }],
+      },
+    })
     session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
     expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } })
 
@@ -133,34 +151,74 @@ describe('SessionHistoryController', () => {
     const { ctx, transport } = await setup()
     const sessionId = SessionId('cold-race')
     const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
-    const listed = deferred<readonly SessionHeader[]>()
-    ctx.provide('sessionPersistence', {
-      list: () => listed.promise,
-      inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }),
-    } as never)
+    const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>()
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+      inspect: () => inspected.promise,
+    }) as never)
     const abort = new AbortController()
     const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
       [Symbol.asyncIterator]()
     const opening = iterator.next()
 
-    ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0))
-    ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0))
-    listed.resolve([header])
-    await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
+    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))
+    inspected.resolve({ meta: header, events: [event('fixture/start', 0)] })
+    await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
 
     const waiting = iterator.next()
     abort.abort()
     await expect(waiting).resolves.toMatchObject({ done: true })
   })
 
+  it('buffers creation while the opening observation is unresolved', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const sessionId = SessionId('created-during-observation')
+    const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
+    const observed = deferred<SessionObservation>()
+    ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never)
+    const transport = new SessionHistoryController(ctx, vi.fn())
+    const abort = new AbortController()
+    const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
+      [Symbol.asyncIterator]()
+    const opening = iterator.next()
+
+    const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] })
+    observed.resolve({
+      source: 'live',
+      header: attached.header,
+      events: attached.events,
+      cursor: attached.seq - 1,
+      projections: { asOfSeq: attached.seq - 1, values: {} },
+      retain: vi.fn(),
+      [Symbol.dispose]: vi.fn(),
+    } as unknown as SessionObservation)
+    await expect(opening).resolves.toMatchObject({
+      done: false,
+      value: {
+        type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }],
+      },
+    })
+    expect(attached.id).toBe(sessionId)
+    abort.abort()
+    await expect(iterator.next()).resolves.toMatchObject({ done: true })
+  })
+
   it('bridges the unpublished end-seed boundary when a cold source attaches', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
+    installSessionReadTestServices(ctx)
     let transport!: SessionHistoryController
     let agentCtx!: Context
     await ctx.plugin(Object.assign(
-      (inner: Context) => { transport = new SessionHistoryController(inner) },
-      { inject: ['sessions'] },
+      (inner: Context) => {
+        transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
+      },
+      { inject: ['sessions', 'sessionQuery'] },
     ))
     await ctx.plugin(Object.assign(
       (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
@@ -179,7 +237,7 @@ describe('SessionHistoryController', () => {
     const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
       [Symbol.asyncIterator]()
 
-    await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
+    await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
     agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
     const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
     agentCtx.sessions.enter(attached)
@@ -212,11 +270,9 @@ describe('SessionHistoryController', () => {
     const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' }
     cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)])
     const replayed = replay.transport.follow({
-      address: { kind: 'session', sessionId: replayId }, afterSeq: -1,
+      address: { kind: 'session', sessionId: replayId },
     }, signal())[Symbol.asyncIterator]()
-    await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
-    await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } })
-    await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
 
     const live = await setup()
     const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
@@ -225,8 +281,13 @@ describe('SessionHistoryController', () => {
     const followed = live.transport.follow({
       address: { kind: 'session', sessionId: session.id },
     }, signal())[Symbol.asyncIterator]()
-    await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
-    live.ctx.emit('session/event', session, event('fixture/gap', 2))
+    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)
     await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
   })
 
@@ -237,7 +298,7 @@ describe('SessionHistoryController', () => {
     const iterator = transport.follow({
       address: { kind: 'session', sessionId: session.id },
     }, abort.signal)[Symbol.asyncIterator]()
-    await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } })
+    await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
     await expect(transport.page({
       address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
     }, signal())).resolves.toMatchObject({ events: [], hasMore: false })
@@ -245,6 +306,59 @@ describe('SessionHistoryController', () => {
     await expect(iterator.next()).resolves.toMatchObject({ done: true })
   })
 
+  it('publishes an empty projection baseline when the query has no registry', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const sessionId = SessionId('projectionless-follow')
+    const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
+    ctx.provide('sessionQuery', {
+      observeSession: () => Promise.resolve({
+        source: 'live', header: meta, events: [], cursor: -1,
+        retain: vi.fn(), [Symbol.dispose]: vi.fn(),
+      } satisfies SessionObservation),
+    } as never)
+    const history = new SessionHistoryController(ctx, vi.fn())
+    const abort = new AbortController()
+    const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal)
+      [Symbol.asyncIterator]()
+
+    await expect(iterator.next()).resolves.toMatchObject({
+      value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } },
+    })
+    abort.abort()
+    await expect(iterator.next()).resolves.toMatchObject({ done: true })
+    await ctx.fiber.dispose()
+  })
+
+  it('disposes a retained promotion when background activation rejects synchronously', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const sessionId = SessionId('promotion-failure')
+    const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
+    const disposePromotion = vi.fn()
+    const promotion = {
+      source: 'prepared', header: meta, events: [], cursor: -1,
+      projections: { asOfSeq: -1, values: {} },
+      retain: vi.fn(), [Symbol.dispose]: disposePromotion,
+    } as unknown as SessionObservation
+    const source = {
+      ...promotion,
+      retain: () => promotion,
+      [Symbol.dispose]: vi.fn(),
+    } as SessionObservation
+    ctx.provide('sessionQuery', {
+      observeSession: () => Promise.resolve(source),
+    } as never)
+    const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') })
+    const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal())
+      [Symbol.asyncIterator]()
+
+    await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
+    await expect(iterator.next()).rejects.toThrow('activation failed')
+    expect(disposePromotion).toHaveBeenCalledOnce()
+    await ctx.fiber.dispose()
+  })
+
   it('requires the durable parent and mode for a direct subagent address', async () => {
     const { ctx, transport } = await setup()
     const parentSessionId = SessionId('parent')
@@ -288,15 +402,18 @@ describe('SessionHistoryController', () => {
     const sessionId = SessionId('corrupt-cold')
     const failure = new Error('cold log is corrupt')
     const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
-    ctx.provide('sessionPersistence', {
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
       list: () => Promise.resolve([header]),
       inspect: () => Promise.reject(failure),
-    } as never)
+    }) as never)
 
     await expect(transport.page({
       address: { kind: 'session', sessionId },
       throughSeq: -1,
-    }, new AbortController().signal)).rejects.toBe(failure)
+    }, new AbortController().signal)).rejects.toMatchObject({
+      code: 'SESSION_QUERY_PERSISTENCE_FAILED',
+      cause: failure,
+    })
   })
 
   it('rejects malformed page and follow cursors at the service boundary', async () => {
@@ -325,25 +442,24 @@ describe('SessionHistoryController', () => {
     )
     await expect(corrupt.transport.page({
       address: { kind: 'session', sessionId: corruptId }, throughSeq: 1,
-    }, signal())).rejects.toMatchObject({ failure: { code: 'internal' } })
-    for (const afterSeq of [-2, 0.5]) {
-      const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]()
+    }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
+    for (const maxMessages of [0, 0.5]) {
+      const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
       await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
     }
-    const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]()
-    await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
   })
 
   it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
     const { ctx, transport } = await setup()
     const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
     await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'internal' } })
+      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
 
-    ctx.provide('sessionPersistence', {
+    const inspect = vi.fn(() => Promise.resolve(undefined))
+    ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
       list: () => Promise.resolve([]),
-      inspect: () => Promise.reject(new Error('must not inspect')),
-    } as never)
+      inspect,
+    }) as never)
     await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
       .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
     await expect(transport.page({
@@ -355,25 +471,28 @@ describe('SessionHistoryController', () => {
       },
       throughSeq: -1,
     }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
+    expect(inspect).toHaveBeenCalledTimes(2)
   })
 
   it('rejects incomplete cold metadata before serving a source', async () => {
     const first = await setup()
     const sessionId = SessionId('incomplete')
     const address = { kind: 'session' as const, sessionId }
-    first.ctx.provide('sessionPersistence', {
-      list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]),
-      inspect: () => Promise.reject(new Error('must not inspect')),
-    } as never)
+    const firstHeader = { version: 0, id: sessionId, createdAt: 1 }
+    first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, {
+      list: () => Promise.resolve([firstHeader]),
+      inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
+    }) as never)
     await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
       .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
 
     const second = await setup()
     const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
-    second.ctx.provide('sessionPersistence', {
+    const inspected = { version: 0, id: sessionId, createdAt: 1 }
+    second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, {
       list: () => Promise.resolve([listed]),
-      inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }),
-    } as never)
+      inspect: () => Promise.resolve({ meta: inspected, events: [] }),
+    }) as never)
     await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
       .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
   })
@@ -407,7 +526,7 @@ describe('SessionHistoryController', () => {
     const missing = await setup()
     cold(missing.ctx, childHeader, [])
     await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } })
+      .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
 
     const corrupt = await setup()
     cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
@@ -421,44 +540,48 @@ describe('SessionHistoryController', () => {
       .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
   })
 
-  it('uses attached and detached projection cuts and isolates a child projection failure', async () => {
-    const attached = await setup()
-    const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
+  it('reports an unavailable descriptor when an observed child has no projection value', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const parentSessionId = SessionId('missing-projection-parent')
+    const childSessionId = SessionId('missing-projection-child')
+    const meta: SessionHeader = {
+      version: 0,
+      id: childSessionId,
+      createdAt: 1,
+      cwd: '/workspace',
+      origin: 'subagent',
+      parentSession: parentSessionId,
+    }
+    ctx.provide('sessionQuery', {
+      observeSession: () => Promise.resolve({
+        source: 'live', header: meta, events: [], cursor: -1,
+        projections: { asOfSeq: -1, values: {} },
+        retain: vi.fn(), [Symbol.dispose]: vi.fn(),
+      } as unknown as SessionObservation),
+    } as never)
+    const history = new SessionHistoryController(ctx, vi.fn())
+
+    await expect(history.page({
+      address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
+      throughSeq: -1,
+    }, signal())).rejects.toMatchObject({
+      failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
+    })
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps pages projection-free and computes projections only for child authorization', async () => {
+    const ordinary = await setup()
+    const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
     session.append('turn/start', { turn: 1 })
-    const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } }))
-    attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never)
-    await expect(attached.transport.page({
+    const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot')
+    const ordinaryPage = await ordinary.transport.page({
       address: { kind: 'session', sessionId: session.id },
       throughSeq: 0,
-    }, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } })
-    expect(snapshot).toHaveBeenCalledWith(session)
-    const older = await attached.transport.page({
-      address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1,
     }, signal())
-    expect('projections' in older).toBe(false)
-
-    const detached = await setup()
-    const coldId = SessionId('projected-cold')
-    const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' }
-    cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })])
-    const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } }))
-    detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never)
-    await expect(detached.transport.page({
-      address: { kind: 'session', sessionId: coldId },
-      throughSeq: 0,
-    }, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } })
-    expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0)
-
-    const failed = await setup()
-    cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })])
-    failed.ctx.provide('sessionProjections', {
-      snapshot: vi.fn(),
-      restore: () => { throw new Error('projection failed') },
-    } as never)
-    await expect(failed.transport.page({
-      address: { kind: 'session', sessionId: coldId },
-      throughSeq: 0,
-    }, signal())).rejects.toThrow('projection failed')
+    expect('projections' in ordinaryPage).toBe(false)
+    expect(ordinarySnapshot).not.toHaveBeenCalled()
 
     const child = await setup()
     const parentSessionId = SessionId('projection-parent')
@@ -469,17 +592,13 @@ describe('SessionHistoryController', () => {
     childSession.append('subagent/descriptor', snapshotSubagentDescriptor({
       mode: 'continuable', provider: 'test', label: 'child',
     }))
-    const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined)
-    child.ctx.provide('sessionProjections', {
-      snapshot: () => { throw new Error('child projection failed') },
-      restore: vi.fn(),
-    } as never)
+    const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
     const page = await child.transport.page({
       address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
       throughSeq: 0,
     }, signal())
     expect('projections' in page).toBe(false)
-    expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
+    expect(childSnapshot).toHaveBeenCalledWith(childSession)
   })
 
   it('keeps message-aligned pagination contiguous across replacement provenance', async () => {

+ 2 - 1
packages/api/session-controller/tsconfig.host.json

@@ -15,7 +15,8 @@
     "src/commands.ts",
     "src/control.ts",
     "src/history.ts",
-    "src/list.ts"
+    "src/list.ts",
+    "src/model-selection-projection.ts"
   ],
   "references": [
     { "path": "../../../vendor/cordis" },

+ 1 - 1
packages/client/connection/src/client/api.ts

@@ -10,7 +10,7 @@ export type {
   DirectoryEntry, DirectoryListing,
   ResponseValue,
   SkillsApi, SkillEntry,
-  ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
+  ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
   ModelReasoningEffort, ModelSelection,
   GoalsApi, GoalRef,
   SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,

+ 99 - 40
packages/client/connection/src/client/fixture.ts

@@ -19,6 +19,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
 import type {
   JsonValue,
   SessionEvent,
+  SessionHeader,
   SessionId,
 } from '@deepseek-ai/dsh-session/types'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
@@ -86,7 +87,7 @@ type FixtureSessionAddress =
 
 interface FixtureFollowRequest {
   readonly address: FixtureSessionAddress
-  readonly afterSeq?: number
+  readonly maxMessages?: number
 }
 
 interface FixturePageRequest {
@@ -97,7 +98,14 @@ interface FixturePageRequest {
 }
 
 type FixtureFollowFrame =
-  | { readonly type: 'opened'; readonly cursor: number }
+  | {
+    readonly type: 'snapshot'
+    readonly header: SessionHeader
+    readonly cursor: number
+    readonly events: readonly FixtureHistoryEntry[]
+    readonly hasMore: boolean
+    readonly projections: FixtureProjectionsBlock
+  }
   | ({ readonly type: 'event' } & FixtureHistoryEntry)
 
 type FixtureFollowEventFrame = Extract<FixtureFollowFrame, { type: 'event' }>
@@ -207,7 +215,6 @@ interface FixtureSessionApi {
     readonly beforeSeq?: number
     readonly maxMessages?: number
   }): Promise<ConnectionRpcResult<unknown>>
-  models(request: { readonly sessionId: SessionId }): Promise<ConnectionRpcResult<unknown>>
   selectModel(request: {
     readonly sessionId: SessionId
     readonly provider: string
@@ -522,7 +529,7 @@ const OPENAI_REASONING = {
   defaultEffort: 'medium',
 }
 
-/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */
+/** Catalog served by `llm.models` (fresh copies per call). */
 function fixtureModelGroups(): ModelProviderGroup[] {
   return [
     {
@@ -1206,6 +1213,7 @@ function contextPressureOf(
 
 function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
   const values: Record<string, unknown> = {}
+  values['modelSelection'] = modelSelectionProjectionOf(log)
   const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
   if (titleEvent !== undefined) {
     values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
@@ -1242,6 +1250,37 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
   return values
 }
 
+function modelSelectionProjectionOf(log: readonly SessionEvent[]): {
+  lastUsed: ModelSelection | null
+  next: ModelSelection | null
+} {
+  let lastUsed: ModelSelection | null = null
+  let pending: ModelSelection | null = null
+  for (const event of log) {
+    if ((event as { type: string }).type === 'model/selection') {
+      pending = (event as unknown as { data: ModelSelection }).data
+      continue
+    }
+    if (event.type !== 'request/header') continue
+    lastUsed = {
+      provider: event.data.header.config.provider,
+      model: event.data.header.config.model,
+      ...(event.data.header.config.reasoningEffort === undefined
+        ? {}
+        : { reasoningEffort: event.data.header.config.reasoningEffort }),
+    }
+    if (sameModelSelection(pending, lastUsed)) pending = null
+  }
+  return { lastUsed, next: pending ?? lastUsed }
+}
+
+function sameModelSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
+  return left === right || (left !== null && right !== null
+    && left.provider === right.provider
+    && left.model === right.model
+    && left.reasoningEffort === right.reasoningEffort)
+}
+
 /** Host parallel: emit one Session control projection frame per key advanced by the event. */
 function projectionFramesOf(
   id: SessionId,
@@ -1250,6 +1289,15 @@ function projectionFramesOf(
 ): FixtureProjectionFrame[] {
   const type = (event as { type: string }).type
   const frames: FixtureProjectionFrame[] = []
+  if (type === 'model/selection' || type === 'request/header') {
+    frames.push({
+      type: 'projection',
+      sessionId: id,
+      key: 'modelSelection',
+      value: modelSelectionProjectionOf(log),
+      seq: event.seq,
+    })
+  }
   // One usage sample advances both token-meter units.
   if (usageSampleOf(event) !== undefined) {
     frames.push(
@@ -2623,28 +2671,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const boundedLog = log.slice(0, throughSeq + 1)
       // Snapshot at request time, then deliver after the transit delay.
       const page = pageOf(boundedLog, request.beforeSeq, request.maxMessages ?? 50)
-      // Tail page carries the projections block (host parallel: one consistent
-      // cut over the registered units; asOfSeq = window tail seq, -1 on an
-      // empty log — the host's session.seq-1 convention).
-      const projections = request.beforeSeq === undefined
-        ? { asOfSeq: throughSeq, values: projectionValuesOf(boundedLog) }
-        : undefined
       const doomed = failNextHistory
       failNextHistory = false
       const delay = historyDelayMs
       if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
       if (doomed) throw new Error('fixture: simulated history transport failure')
-      return sessionOk({ ...page, ...projections === undefined ? {} : { projections } })
+      return sessionOk(page)
     },
-    models: request => sessionOk({
-      current: modelSelections.get(request.sessionId)
-          ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
-      // The fixture's routes all serve; a surface exercising the blocked
-      // posture drives it through its own stub.
-      routable: true,
-      groups: fixtureModelGroups(),
-      failures: [],
-    }),
     selectModel: (request) => {
       const selected: ModelSelection = {
         provider: request.provider,
@@ -2653,6 +2686,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           ? {}
           : { reasoningEffort: request.reasoningEffort },
       }
+      append(request.sessionId, { type: 'model/selection', data: selected })
       modelSelections.set(request.sessionId, selected)
       return sessionOk({ selected })
     },
@@ -2717,6 +2751,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       // log-only, appended inside the open turn, and deduplicated against the
       // route already recorded (the fixture never varies contextWindow).
       const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
+      const previousHeader = logOf(id).findLast(event => event.type === 'request/header')
+      const previousSelection = previousHeader?.type === 'request/header'
+        ? {
+          provider: previousHeader.data.header.config.provider,
+          model: previousHeader.data.header.config.model,
+          ...(previousHeader.data.header.config.reasoningEffort === undefined
+            ? {}
+            : { reasoningEffort: previousHeader.data.header.config.reasoningEffort }),
+        }
+        : null
+      if (!sameModelSelection(previousSelection, selection)) {
+        append(id, {
+          type: 'request/header',
+          data: {
+            header: { config: selection },
+            reason: previousHeader === undefined ? 'initial' : 'change',
+          },
+        })
+      }
       if (lastRequestContext(logOf(id))?.model !== selection.model) {
         append(id, {
           type: 'request/context',
@@ -2897,23 +2950,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     streamBreakers.add(breakNow)
     const snapshot = [...logOf(sessionId)]
     const cursor = snapshot.at(-1)?.seq ?? -1
-    if (request.afterSeq !== undefined && request.afterSeq > cursor) {
-      throw new Error(
-        `fixture: session event resume seq ${String(request.afterSeq)} is past cursor ${String(cursor)}`,
-      )
-    }
-    let nextSeq = (request.afterSeq ?? cursor) + 1
+    const summary = summaryOf(sessionId)
+    /* v8 ignore next -- existence was checked before the stream registered. */
+    if (summary === undefined) throw new Error(`fixture: no session ${sessionId}`)
+    const initial = pageOf(snapshot, undefined, request.maxMessages ?? 50)
+    let nextSeq = cursor + 1
     try {
-      yield { type: 'opened', cursor }
-      if (request.afterSeq !== undefined) {
-        for (const event of snapshot) {
-          if (event.seq < nextSeq) continue
-          if (event.seq !== nextSeq) {
-            throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
-          }
-          nextSeq++
-          yield { type: 'event', event }
-        }
+      yield {
+        type: 'snapshot',
+        header: {
+          version: 0,
+          id: sessionId,
+          createdAt: summary.updatedAt,
+          ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
+          ...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }),
+          ...(summary.origin === undefined ? {} : { origin: summary.origin }),
+          ...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }),
+        },
+        cursor,
+        events: initial.events,
+        hasMore: initial.hasMore,
+        projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) },
       }
       for await (const frame of conn.drain(signal)) {
         if (frame.event.seq < nextSeq) continue
@@ -3346,7 +3403,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
         ],
       }),
-      models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
+      models: request => ok(request, {
+        default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+        routableProviders: ['deepseek-official', 'openai', 'acme-gateway'],
+        groups: fixtureModelGroups(),
+        failures: [],
+      }),
       // The fixture endpoint is imaginary, so the interrogation answers the
       // catalog it already serves — enough for a surface to exercise adopting
       // candidates without a reachable provider.
@@ -3411,9 +3473,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         case 'session/create': return sessionApi.create(
           request as Parameters<FixtureSessionApi['create']>[0],
         )
-        case 'session/models': return sessionApi.models(
-          request as Parameters<FixtureSessionApi['models']>[0],
-        )
         case 'session/selectModel': return sessionApi.selectModel(
           request as Parameters<FixtureSessionApi['selectModel']>[0],
         )

+ 1 - 1
packages/client/connection/src/client/index.ts

@@ -33,7 +33,7 @@ export type {
   ApiProxy, HostApi,
   DirectoryEntry, DirectoryListing,
   SkillsApi, SkillEntry,
-  ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
+  ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
   MessageId, ModelReasoningEffort, ModelSelection,
   SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,

+ 6 - 1
packages/client/connection/tests/fake-api.client.ts

@@ -141,7 +141,12 @@ export class FakeApiClient implements IApiClient {
 
   readonly llm: IApiClient['llm'] = {
     providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
-    models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
+    models: payload => this.record('llm.models', payload, Promise.resolve(ok({
+      default: { provider: 'fixture', model: 'fixture' },
+      routableProviders: [],
+      groups: [],
+      failures: [],
+    }))),
     discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
   }
 

+ 25 - 80
packages/client/connection/tests/fixture.client.spec.ts

@@ -1,6 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type {
-  ModelProviderGroup,
   ModelSelection,
   RpcMessage,
   RpcRequest,
@@ -42,14 +41,19 @@ interface FixtureHistoryEntry {
 interface FixturePage {
   readonly events: readonly FixtureHistoryEntry[]
   readonly hasMore: boolean
-  readonly projections?: {
-    readonly asOfSeq: number
-    readonly values: Readonly<Record<string, unknown>>
-  }
 }
 
 type FixtureFollowFrame =
-  | { readonly type: 'opened'; readonly cursor: number }
+  | {
+    readonly type: 'snapshot'
+    readonly cursor: number
+    readonly events: readonly FixtureHistoryEntry[]
+    readonly hasMore: boolean
+    readonly projections: {
+      readonly asOfSeq: number
+      readonly values: Readonly<Record<string, unknown>>
+    }
+  }
   | ({ readonly type: 'event' } & FixtureHistoryEntry)
 
 type FixtureControlFrame =
@@ -88,7 +92,6 @@ interface FixtureSessionRequests {
     readonly beforeSeq?: number
     readonly maxMessages?: number
   }
-  models: { readonly sessionId: SessionId }
   selectModel: {
     readonly sessionId: SessionId
     readonly provider: string
@@ -114,12 +117,6 @@ interface FixtureSessionValues {
   search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean }
   create: { readonly sessionId: SessionId }
   history: FixturePage
-  models: {
-    readonly current: ModelSelection
-    readonly routable: boolean
-    readonly groups: readonly ModelProviderGroup[]
-    readonly failures: readonly unknown[]
-  }
   selectModel: { readonly selected: ModelSelection }
   prompt: { readonly accepted: true }
   cancel: Record<never, never>
@@ -141,7 +138,7 @@ type FixtureSessionClient = {
 }
 
 interface FixtureSessionRemote {
-  follow(sessionId: SessionId, signal: AbortSignal, afterSeq?: number): AsyncIterable<FixtureFollowFrame>
+  follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable<FixtureFollowFrame>
   control(signal: AbortSignal): AsyncIterable<FixtureControlFrame>
 }
 
@@ -331,7 +328,6 @@ function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi {
     search: (request, signal) => call('search', request, signal),
     create: (request, signal) => call('create', request, signal),
     history: (request, signal) => call('history', request, signal),
-    models: (request, signal) => call('models', request, signal),
     selectModel: (request, signal) => call('selectModel', request, signal),
     prompt: (request, signal) => call('prompt', request, signal),
     cancel: (request, signal) => call('cancel', request, signal),
@@ -346,7 +342,6 @@ function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient {
     search: (request, signal) => api.search(req(request), signal),
     create: (request, signal) => api.create(req(request), signal),
     history: (request, signal) => api.history(req(request), signal),
-    models: (request, signal) => api.models(req(request), signal),
     selectModel: (request, signal) => api.selectModel(req(request), signal),
     prompt: (request, signal) => api.prompt(req(request), signal),
     cancel: (request, signal) => api.cancel(req(request), signal),
@@ -361,11 +356,8 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote {
     return stream as AsyncIterable<F>
   }
   return {
-    follow: (sessionId, signal, afterSeq) => open<FixtureFollowFrame>('session/follow', {
-      request: {
-        address: { kind: 'session', sessionId },
-        ...afterSeq === undefined ? {} : { afterSeq },
-      },
+    follow: (sessionId, signal) => open<FixtureFollowFrame>('session/follow', {
+      request: { address: { kind: 'session', sessionId } },
     }, signal),
     control: signal => open<FixtureControlFrame>('session/control', {}, signal),
   }
@@ -499,7 +491,7 @@ async function nextRemoteEvent(
 async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise<number> {
   const abort = new AbortController()
   for await (const frame of remote.follow(sessionId, abort.signal)) {
-    if (frame.type !== 'opened') continue
+    if (frame.type !== 'snapshot') continue
     abort.abort()
     return frame.cursor
   }
@@ -599,52 +591,10 @@ describe('createFixtureApi', () => {
     const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
     if (!clamped.result.ok) throw new Error('clamped failed')
     expect(clamped.result.value.events).toEqual([])
-    // Unknown session: empty page, not an error (history of a bare id). The
-    // tail block still rides it — empty-log cut at -1, the host convention.
+    // Unknown session: empty page, not an error (history of a bare id).
     const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
     if (!empty.result.ok) throw new Error('empty failed')
-    // Fixture composes the todos + plan units (host parallel when tool-todo
-    // and plan-mode are mounted): the empty-log values.
-    expect(empty.result.value).toEqual({
-      events: [], hasMore: false, projections: { asOfSeq: -1, values: {
-        todos: null,
-        // Permission unit composed: the composition-default select.
-        permissions: {
-          options: [
-            { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
-            { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
-          ],
-          currentValue: 'workspace-write',
-        },
-        plan: { active: false, pending: false },
-        goal: null,
-        tokenUsage: {
-          uncachedInputTokens: 0,
-          outputTokens: 0,
-          cacheReadTokens: 0,
-          cacheWriteTokens: 0,
-        },
-        // No request ran, so neither pressure nor capacity is known yet.
-        contextPressure: {},
-        contextBreakdown: {
-          systemTokens: 0,
-          toolsTokens: 0,
-          messageTokens: 0,
-        },
-        // Session-stats unit composed: no figure accrues on the empty log.
-        sessionStats: {
-          turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
-        },
-        imageLimits: {
-          maxImageBytes: 5 * 1024 * 1024,
-          maxImagesPerMessage: 20,
-          maxMessageImageBytes: 100 * 1024 * 1024,
-          maxImagePixels: 40_000_000,
-          maxImageDimension: 2000,
-          mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
-        },
-      } },
-    })
+    expect(empty.result.value).toEqual({ events: [], hasMore: false })
   })
 
   it('serves raw history entries with replayable tool-result metadata', async () => {
@@ -693,7 +643,7 @@ describe('createFixtureApi', () => {
   it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
     const api = createFixtureApi()
     const sessionId = sid('fx-alpha')
-    const catalog = await api.sessions.models(req({ sessionId }))
+    const catalog = await api.llm.models(req({}))
     if (!catalog.result.ok) throw new Error('models failed')
     expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
     expect(catalog.result.value.groups[0]?.models.map(model => model.id))
@@ -1462,33 +1412,29 @@ describe('createFixtureApi', () => {
     const gapAbort = new AbortController()
     const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]()
     const opening = await gapIterator.next()
-    if (opening.done || opening.value.type !== 'opened') throw new Error('follow opening cursor missing')
-    const resumeCursor = opening.value.cursor
+    if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing')
     hooks.appendSilent('fx-alpha', '静默丢帧')
     hooks.appendUser('fx-alpha', '正常直播')
     await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/)
 
-    // Reopening from the established cursor replays both durable events.
+    // Reopening replaces the window with a complete snapshot containing both durable events.
     const followAbort = new AbortController()
     const controlAbort = new AbortController()
     const followed: FixtureFollowFrame[] = []
     const controlled: FixtureControlFrame[] = []
     const following = (async () => {
-      for await (const frame of api.sessionRemote.follow(
-        sid('fx-alpha'),
-        followAbort.signal,
-        resumeCursor,
-      )) followed.push(frame)
+      for await (const frame of api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal)) {
+        followed.push(frame)
+      }
     })()
     const controlling = (async () => {
       for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame)
     })()
     await new Promise(resolve => setTimeout(resolve, 10))
     await vi.waitFor(() => {
-      expect(followed.some(frame => frame.type === 'event'
-        && JSON.stringify(frame.event.data).includes('静默丢帧'))).toBe(true)
-      expect(followed.some(frame => frame.type === 'event'
-        && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true)
+      const snapshot = followed.find(frame => frame.type === 'snapshot')
+      expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('静默丢帧'))).toBe(true)
+      expect(snapshot?.events.some(entry => JSON.stringify(entry.event.data).includes('正常直播'))).toBe(true)
     })
     hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
     hooks.beginModelRetry('fx-alpha')
@@ -1497,7 +1443,6 @@ describe('createFixtureApi', () => {
     hooks.beginModelRetry('fx-alpha')
     hooks.cancelModelRetryDuringBackoff('fx-alpha')
     await vi.waitFor(() => {
-      expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true)
       expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true)
       expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true)
       expect(followed.some(frame => frame.type === 'event'

+ 2 - 0
packages/client/ui-agent-preset/package.json

@@ -53,6 +53,7 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -66,6 +67,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",

+ 5 - 1
packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx

@@ -14,6 +14,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
 import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 // Type-only: pulls the ui-conversation SlotMap merge (the header actions).
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import type { AgentPresetSettingsState } from './settings-store.ts'
 import { presetDisplayText } from './locales.ts'
 import css from './AgentPresetLabel.module.css'
@@ -42,7 +43,10 @@ export type AgentPresetLabelProps =
 export function AgentPresetLabel({
   sessionId, useSessions, useAgentPresets, load, t,
 }: AgentPresetLabelProps) {
-  const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
+  const preset = useSessions((state) => {
+    const value = state.byId[sessionId]?.projectionValues?.agentPreset
+    return typeof value === 'string' ? value : undefined
+  })
   const options = useAgentPresets(state => state.options)
 
   useEffect(() => {

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