Преглед изворни кода

Merge remote-tracking branch 'origin/master' into fix/web-fetch-ssrf

Dudu-0223 пре 3 недеља
родитељ
комит
8bf8e42b63
100 измењених фајлова са 2545 додато и 1399 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 10 10
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 10 10
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 3 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 3 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  8. 5 5
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  9. 5 5
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  11. 20 14
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  12. 20 14
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
  14. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
  15. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
  17. 201 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
  18. 201 0
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
  19. 5 1
      THIRD_PARTY_NOTICES.md
  20. 1 0
      apps/cli/package.json
  21. 10 15
      apps/cli/tests/github-webhook-real.e2e.ts
  22. 3 0
      apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml
  23. 14 0
      apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts
  24. 3 18
      apps/cli/tests/web-agent-presets.e2e.ts
  25. 3 0
      apps/cli/tsconfig.json
  26. 11 2
      apps/web/tests/agent-preset-selection.e2e.ts
  27. 7 6
      apps/web/tests/assembled-boot.ts
  28. 12 0
      apps/web/tests/cordis-tool-round.e2e.ts
  29. 8 3
      apps/web/tests/default-model.e2e.ts
  30. 2 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  31. 1 1
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  32. 1 1
      apps/web/tests/reference-composer.e2e.ts
  33. 6 1
      apps/web/tests/scaffold.ts
  34. 16 25
      apps/web/tests/seeded-history.e2e.ts
  35. 1 1
      apps/web/tests/settings-chrome.e2e.ts
  36. 8 2
      apps/web/tests/shipped-composition.e2e.ts
  37. 18 11
      apps/web/tests/smoke-real.e2e.ts
  38. 39 67
      apps/web/tests/startup-auto-selection.e2e.ts
  39. 32 0
      apps/web/tests/subagent-conversation.e2e.ts
  40. 2 2
      docs/config-catalog.i18n.yaml
  41. 12 6
      docs/config-catalog.md
  42. 12 6
      docs/config-catalog.zh.md
  43. 2 2
      docs/event-producer-consumer.i18n.yaml
  44. 7 7
      docs/event-producer-consumer.md
  45. 7 7
      docs/event-producer-consumer.zh.md
  46. 2 2
      docs/module-graph.i18n.yaml
  47. 129 124
      docs/module-graph.md
  48. 129 124
      docs/module-graph.zh.md
  49. 2 2
      docs/persistence-catalog.i18n.yaml
  50. 17 1
      docs/persistence-catalog.md
  51. 17 1
      docs/persistence-catalog.zh.md
  52. 2 2
      docs/subsystems/client-modules.i18n.yaml
  53. 12 12
      docs/subsystems/client-modules.md
  54. 12 12
      docs/subsystems/client-modules.zh.md
  55. 2 2
      docs/subsystems/persistence.i18n.yaml
  56. 11 0
      docs/subsystems/persistence.md
  57. 11 0
      docs/subsystems/persistence.zh.md
  58. 2 2
      docs/subsystems/session-projection.i18n.yaml
  59. 50 10
      docs/subsystems/session-projection.md
  60. 50 10
      docs/subsystems/session-projection.zh.md
  61. 2 2
      docs/subsystems/session-query.i18n.yaml
  62. 8 0
      docs/subsystems/session-query.md
  63. 8 0
      docs/subsystems/session-query.zh.md
  64. 2 2
      docs/subsystems/session.i18n.yaml
  65. 2 9
      docs/subsystems/session.md
  66. 2 9
      docs/subsystems/session.zh.md
  67. 2 2
      docs/subsystems/subagent.i18n.yaml
  68. 7 18
      docs/subsystems/subagent.md
  69. 7 18
      docs/subsystems/subagent.zh.md
  70. 2 2
      docs/subsystems/web-client.i18n.yaml
  71. 2 2
      docs/subsystems/web-client.md
  72. 2 2
      docs/subsystems/web-client.zh.md
  73. 2 2
      docs/subsystems/web-server.i18n.yaml
  74. 10 4
      docs/subsystems/web-server.md
  75. 10 4
      docs/subsystems/web-server.zh.md
  76. 59 61
      packages/api/gateway/src/client/journal-stream.ts
  77. 353 222
      packages/api/gateway/tests/journal-stream.client.spec.ts
  78. 1 1
      packages/api/remotes/src/client/index.ts
  79. 0 1
      packages/api/session-controller/package.json
  80. 158 45
      packages/api/session-controller/src/agent.ts
  81. 11 7
      packages/api/session-controller/src/catalog.ts
  82. 0 8
      packages/api/session-controller/src/client/contract/sessions.ts
  83. 5 1
      packages/api/session-controller/src/client/contract/snapshot.ts
  84. 0 2
      packages/api/session-controller/src/client/sessions/lineage.ts
  85. 29 29
      packages/api/session-controller/src/client/sessions/manager.ts
  86. 3 4
      packages/api/session-controller/src/client/sessions/projection-store.ts
  87. 0 11
      packages/api/session-controller/src/client/sessions/service.ts
  88. 9 6
      packages/api/session-controller/src/client/sessions/session.ts
  89. 25 11
      packages/api/session-controller/src/client/transport.ts
  90. 18 35
      packages/api/session-controller/src/commands.ts
  91. 7 7
      packages/api/session-controller/src/control.ts
  92. 101 98
      packages/api/session-controller/src/history.ts
  93. 37 17
      packages/api/session-controller/src/index.ts
  94. 93 112
      packages/api/session-controller/src/list.ts
  95. 83 0
      packages/api/session-controller/src/model-selection-projection.ts
  96. 59 21
      packages/api/session-controller/src/types.ts
  97. 143 27
      packages/api/session-controller/tests/agent.host.spec.ts
  98. 24 11
      packages/api/session-controller/tests/commands-create-fork.host.spec.ts
  99. 47 10
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  100. 23 12
      packages/api/session-controller/tests/control-jobs.host.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: dfa9f34276f20ffa99541db1544539d693313a2f
-2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf
+2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc
+2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf

+ 10 - 10
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
 
 The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster.
 
-The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed.
+The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row belongs to an application combo script; static React, Cordis, and UI library identities come from the shell seed.
 
 ### One module system, one plugin governor
 
@@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM
 
 The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
 
-### Batched external-script arrival and source maps
+### Combo external-script arrival and source maps
 
-The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
+The Host snapshots every built plugin artifact and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
 
-The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
+The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX.
 
-The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
+The graph retains each row's revisioned one-resource combo URL for HMR and adds a content-addressed descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Startup combo revisions cover the combined script inputs and indexed map. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
 
 ### The loading flow, end to end
 
@@ -54,11 +54,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
-3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows.
+3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
-**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading the application batch, executes the bootstrap batch as one blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Their shared application URL executes once and registers every remaining factory without materializing it. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
+**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
 
 **Phase two — the plugin face.**
 
@@ -76,8 +76,8 @@ How does a rebuilt bundle become a reload signal? The hmr node half observes it
 
 On the browser side, the driver reloads one plugin per frame, serialized:
 
-1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's individual URL. A live factory would make the next step a no-op.
-2. `prefetch` — load the individual external script and register the fresh factory, while the old fiber still serves. The initial batch never executes again.
+1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op.
+2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script never executes again.
 3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently.
 4. Drain the old fiber's disposers.
 5. Remove owned `<style data-plugin>` tags.
@@ -96,7 +96,7 @@ The current package inventory and build forms live in the [client shell layering
 
 One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
 
-Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
+Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
 
 Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
 

+ 10 - 10
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -28,7 +28,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 [Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
 
-Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他动态图 row 全部经 application 批次到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
+Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他每个动态图 row 都归属一个 application combo 脚本;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
 
 ### 一套模块系统,一个插件治理器
 
@@ -38,13 +38,13 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
 
 vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
 
-### 批量外部脚本到达与源码映射
+### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules row;HTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
-共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
+共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -54,11 +54,11 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
-3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。
+3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
-**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载 application 批次,以一个阻塞式 classic script 执行 bootstrap 批次,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;它们共享的 application URL 只执行一次,并登记其余全部 factory 而不物化。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
+**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
 
 **第二阶段——插件面。**
 
@@ -76,8 +76,8 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 浏览器侧,驱动插件每帧重载一个插件,串行执行:
 
-1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的独立 URL。Factory 还活着会让下一步变成 no-op。
-2. `prefetch`——加载独立外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始批次不会再次执行。
+1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
+2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
 3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
 4. 排空旧 fiber 的各 disposer。
 5. 移除名下的 `<style data-plugin>` 标签。
@@ -96,7 +96,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
 
 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
-2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: a3e310a4a5ab8bc6a40ad8d0336cb94e29c1744f
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
 
-**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
+**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
 
 **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
 
@@ -40,3 +40,5 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 | env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
 | Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
 | json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |
+| Public response writer plus per-route opt-in | Response coding is Node HTTP policy; exposing it through `ctx.webServer` would make every route owner and test double depend on that policy |
+| Hand-written gzip negotiation and stream lifecycle | Maintained middleware already owns negotiation, media-type filtering, header rewriting, backpressure, and threshold behavior |

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。
 
-**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
+**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
 
 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。
 
@@ -40,3 +40,5 @@ Status: implemented
 | env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
 | create 不等预取(以 `arrive()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
 | json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,profile 编写者要懂 cordis |
+| 公开响应写出方法并让每条 route 选择接入 | 响应编码属于 Node HTTP 策略;经 `ctx.webServer` 暴露会让每个 route 所有者与测试替身依赖这项策略 |
+| 手写 gzip 协商与流生命周期 | 受维护的中间件已经处理协商、媒体类型筛选、响应头改写、背压与阈值行为 |

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.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-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: 8713234735ddb53db27e17a2ea9087de665d86f7
-2026-08-15-client-shells-and-dynamic-packages.zh.md: ec8a2fb358e86f4fe74db13d555b8be8432ad62e
+2026-08-15-client-shells-and-dynamic-packages.md: 016314d10f55e0b590e98944ca417bae658ab56a
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 4e0277d1becab8467521dc21d0e5b7509d1ee993

+ 5 - 5
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -46,12 +46,12 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
 The modules Node half injects the startup protocol into the served HTML in this order:
 
 1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
-2. Start preloading the content-addressed application batch containing every row except modules.
-3. Execute one blocking bootstrap batch containing the ordinary modules factory registration.
-4. Assign `window.__DSH_BOOT__`, including both batch descriptors and every row's individual HMR URL.
+2. Start preloading every content-addressed application combo URL containing the rows other than modules.
+3. Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
+4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 5. Execute the Vite main module.
 
-The bootstrap batch only registers the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
+The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
 
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 
@@ -79,7 +79,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
 
 Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
 
-The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Batch generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR still uses each row's revisioned individual artifact. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
+The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
 
 The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
 

+ 5 - 5
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -46,12 +46,12 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
 Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 
 1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue`、`load()` 与 `create()`。
-2. 开始预加载按内容寻址的 application 批次,其中包含 modules 之外的全部 row。
-3. 执行一个阻塞式 bootstrap 批次,其中包含普通的 modules factory registration。
-4. 赋值 `window.__DSH_BOOT__`,其中包含两个批次描述及每个 row 的独立 HMR URL。
+2. 开始预加载所有按内容寻址的 application combo URL,其中包含 modules 之外的 row。
+3. 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
+4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 5. 执行 Vite 主模块。
 
-Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
+Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
 
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 
@@ -79,7 +79,7 @@ Bootstrap 批次只登记 modules factory。启动内核把原始图与外壳 se
 
 Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
 
-启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。批次生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 仍使用每个 row 带 revision 的独立产物。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
+启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
 
 外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
 

+ 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 不承诺重放。恢复语义由数据类型决定,不再互相模拟。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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-20-webworker-pack-lowering-and-preview.md
-2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692
-2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580
+2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md

@@ -10,9 +10,9 @@ The browser worker can neither compile modules at load nor be served by the prod
 
 ## Decision
 
-**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in.
+**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
 
-**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
+**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
 
 **The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|<built-in-id>` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md

@@ -10,9 +10,9 @@
 
 ## 决定
 
-**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。
+**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
 
-**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
+**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
 
 **仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|<built-in-id>` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。
 

+ 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。

+ 5 - 1
THIRD_PARTY_NOTICES.md

@@ -61,6 +61,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
 | [`clsx`](https://github.com/lukeed/clsx) | MIT |
 | [`commander`](https://github.com/tj/commander.js) | MIT |
+| [`compression`](https://github.com/expressjs/compression) | MIT |
 | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
 | [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
@@ -82,6 +83,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
 | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
+| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
 | [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
 | [`open`](https://github.com/sindresorhus/open) | MIT |
@@ -138,8 +140,10 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
 | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
 | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -155,7 +159,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
 | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
 | [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
-| [`debug`](https://github.com/debug-js/debug) | MIT |
+| [`debug`](https://github.com/visionmedia/debug) | MIT |
 | [`esbuild`](https://github.com/evanw/esbuild) | MIT |
 | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
 | [`execa`](https://github.com/sindresorhus/execa) | MIT |

+ 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. */

+ 7 - 6
apps/web/tests/assembled-boot.ts

@@ -85,6 +85,9 @@ function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): s
   return resolve(dirname(packagePath), relative)
 }
 
+const comboUrl = (ids: readonly string[], rev: string): string =>
+  `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
+
 /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
 function loadAssembledPlugins(): readonly AssembledPlugin[] {
   const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
@@ -103,7 +106,7 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
     plugins.set(entry.name, {
       id: entry.name,
       bundlePath: resolveClientExport(packagePath, pkg),
-      url: `/plugins/${entry.name}/client.js?rev=fx`,
+      url: comboUrl([entry.name], 'fx'),
       rev: 'fx',
       ...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
       ...(declaration.external === undefined ? {} : { external: declaration.external }),
@@ -121,8 +124,6 @@ function loadAssembledPlugins(): readonly AssembledPlugin[] {
 const PLUGINS = loadAssembledPlugins()
 
 const BOOTSTRAP_IDS = ['@deepseek-ai/dsh-client-modules'] as const
-const BOOTSTRAP_URL = '/plugins/_batch/bootstrap/fx/client.js'
-const APPLICATION_URL = '/plugins/_batch/application/fx/client.js'
 
 /** Build the fixture graph after applying per-scenario package exclusions. */
 function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
@@ -138,13 +139,13 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
     batches: [
       ...(bootstrapEntries.length === 0 ? [] : [{
         phase: 'bootstrap' as const,
-        url: BOOTSTRAP_URL,
+        url: comboUrl(bootstrapEntries, 'fx'),
         rev: 'fx',
         entries: bootstrapEntries,
       }]),
       ...(applicationEntries.length === 0 ? [] : [{
         phase: 'application' as const,
-        url: APPLICATION_URL,
+        url: comboUrl(applicationEntries, 'fx'),
         rev: 'fx',
         entries: applicationEntries,
       }]),
@@ -152,7 +153,7 @@ function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
   }
 }
 
-/** Build individual and batch script bodies for one fixture composition. */
+/** Build single-resource and startup combo script bodies for one fixture composition. */
 function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]): Map<string, string> {
   const bundles = new Map(plugins.map(plugin => [
     plugin.url,

+ 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 })

+ 6 - 1
apps/web/tests/scaffold.ts

@@ -492,9 +492,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
           shutdownTimeoutMillis: 1_000,
         },
       },
+    // Use an ephemeral port while preserving the shipped compression policy;
+    // a patch replaces the row's complete config.
     {
       id: 'webserver',
-      config: { host: '127.0.0.1', port: 0 },
+      config: {
+        host: '127.0.0.1', port: 0, compression: 'gzip',
+        compressionLevel: 1, compressionThresholdBytes: 1024,
+      },
     },
     // The bundle's web-runtime row resolves the same built dist under test
     // (apps/web IS @deepseek-ai/dsh-web-frontend); native browser opening and the

+ 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/settings-chrome.e2e.ts

@@ -193,7 +193,7 @@ describe('web e2e: settings modal and General preferences', () => {
     await page.keyboard.press('Escape')
 
     // Hold the real application batch so the shell-owned loading page remains observable.
-    const pluginPattern = /\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/
+    const pluginPattern = /\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/
     let releaseBundles = (): void => {}
     const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
     await page.route(pluginPattern, async (route) => {

+ 8 - 2
apps/web/tests/shipped-composition.e2e.ts

@@ -1,6 +1,6 @@
 // Boots the shipped Web composition over the built dist this lane already uses
 // and asserts what that composition produces: the model-visible tool catalog
-// and file-reference guidance plus its retry, sandbox, and approval defaults.
+// and file-reference guidance plus its HTTP, retry, sandbox, and approval defaults.
 // No browser and no model call — these are composition facts, and the browser
 // scenarios in this lane cover the surface itself.
 import { readFileSync } from 'node:fs'
@@ -76,9 +76,15 @@ afterEach(async () => {
   scaffold = undefined
 })
 
-it('assembles the shipped Web catalog, file-reference guidance, retry policy, and confined access default', async () => {
+it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
   scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
   const ctx = scaffold.ctx
+  const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, {
+    headers: { 'accept-encoding': 'gzip' },
+  })
+  expect(index.headers.get('content-encoding')).toBe('gzip')
+  expect(index.headers.get('vary')).toContain('Accept-Encoding')
+  await index.body?.cancel()
   expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
     {
       "initialDelayMs": 500,

+ 18 - 11
apps/web/tests/smoke-real.e2e.ts

@@ -30,6 +30,8 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir
 
 const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
 
+const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
+
 function waitForReadyLine(child: ChildProcess): Promise<string> {
   return new Promise((resolveReady, reject) => {
     let out = ''
@@ -125,7 +127,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) {
@@ -286,23 +288,28 @@ describe('dsh web keyless CLI smoke', () => {
       // request when the matching script node executes; this count pins both.
       page.on('request', (request) => {
         const url = new URL(request.url())
-        if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) {
-          pluginScripts.push(url.pathname)
+        const resource = `${url.pathname}${url.search}`
+        if (request.resourceType() === 'script' && resource.startsWith('/plugins/??')) {
+          pluginScripts.push(resource)
         }
       })
       page.on('response', (response) => {
-        const path = new URL(response.url()).pathname
-        if (path.startsWith('/plugins/_batch/')) {
-          cacheHeaders.set(path, response.headers()['cache-control'])
+        const url = new URL(response.url())
+        const resource = `${url.pathname}${url.search}`
+        if (resource.startsWith('/plugins/??')) {
+          cacheHeaders.set(resource, response.headers()['cache-control'])
         }
       })
       await page.goto(readyUrl)
       await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
       const batchPaths = [...new Set(pluginScripts)].sort()
-      expect(batchPaths).toEqual([
-        expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/),
-        expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/),
-      ])
+      expect(batchPaths).toHaveLength(2)
+      expect(batchPaths).toContainEqual(expect.stringMatching(
+        /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/,
+      ))
+      expect(batchPaths).toContainEqual(expect.stringMatching(
+        /^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/,
+      ))
       expect([...cacheHeaders.values()]).toEqual([
         'public, max-age=31536000, immutable',
         'public, max-age=31536000, immutable',
@@ -310,7 +317,7 @@ describe('dsh web keyless CLI smoke', () => {
       for (const path of batchPaths) {
         const [scriptResponse, mapResponse] = await Promise.all([
           fetch(`${readyUrl}${path}`),
-          fetch(`${readyUrl}${path}.map`),
+          fetch(`${readyUrl}${comboMapUrl(path)}`),
         ])
         expect(scriptResponse.status).toBe(200)
         expect(mapResponse.status).toBe(200)

+ 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: 8fe96b56f663145cb42b9c6d9da93d28b6939e36
-config-catalog.zh.md: 54b41ffc35dc97923e3091a606efc028474c0b99
+config-catalog.md: 3511638754de996964ab35a31d3018c4092f26d9
+config-catalog.zh.md: 29b83afec6e10bb2cdb57aab2dd56c82256781f9

+ 12 - 6
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
 }
 ```
@@ -833,12 +833,18 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front
 ## `@deepseek-ai/dsh-host-webserver`
 
 ```ts config-catalog
-/** Gateway config: the listen address. */
+/** Web server listen and response-compression config. */
 export interface Config {
   /** Listen host; the two supported values are loopback and all-interfaces. */
   host: '127.0.0.1' | '0.0.0.0'
   /** Listen port; zero requests an OS-assigned port. */
   port: number
+  /** Response compression for socket-backed HTTP requests. @default 'none' */
+  compression?: 'none' | 'gzip'
+  /** Gzip DEFLATE level from 0 through 9. @default 1 */
+  compressionLevel?: number
+  /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
+  compressionThresholdBytes?: number
 }
 ```
 
@@ -1781,7 +1787,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 +1814,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 +1837,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>
 

+ 12 - 6
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
 }
 ```
@@ -835,12 +835,18 @@ export interface Config {
 ## `@deepseek-ai/dsh-host-webserver`
 
 ```ts config-catalog
-/** Gateway config: the listen address. */
+/** Web server listen and response-compression config. */
 export interface Config {
   /** Listen host; the two supported values are loopback and all-interfaces. */
   host: '127.0.0.1' | '0.0.0.0'
   /** Listen port; zero requests an OS-assigned port. */
   port: number
+  /** Response compression for socket-backed HTTP requests. @default 'none' */
+  compression?: 'none' | 'gzip'
+  /** Gzip DEFLATE level from 0 through 9. @default 1 */
+  compressionLevel?: number
+  /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
+  compressionThresholdBytes?: number
 }
 ```
 
@@ -1783,7 +1789,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 +1816,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 +1839,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: e2c78f715e10cab884ea7a0f5d813b8248eeaf95
-event-producer-consumer.zh.md: ffc26f5737dfdbc2306cf199882c112edf68bc1c
+event-producer-consumer.md: 63832e7cb7c2e663b70c3a3154323556c2e91816
+event-producer-consumer.zh.md: 46fe6b2b0328cec8339b5e95301c513e7179066b

+ 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: a06c797800666c8e8c38cfe9bf7efbf8d568c321
-module-graph.zh.md: d907081e984be6a07a3457b95f78f6fd9b336772
+module-graph.md: dbff7efd32332a63397e3bb66dedd8ed20d4e83a
+module-graph.zh.md: 6e52219c398f1c00dc768658962c875de2107907

+ 129 - 124
docs/module-graph.md

@@ -895,6 +895,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
@@ -983,26 +984,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
@@ -1064,60 +1052,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
@@ -1175,6 +1125,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
@@ -1182,19 +1200,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
@@ -1218,37 +1227,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
@@ -1278,6 +1256,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
@@ -1389,6 +1393,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
@@ -1801,7 +1806,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) |
@@ -1817,8 +1822,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) |
@@ -1827,6 +1831,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) |
@@ -1835,27 +1848,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) |
@@ -1869,7 +1874,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

@@ -897,6 +897,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
@@ -985,26 +986,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
@@ -1066,60 +1054,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
@@ -1177,6 +1127,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
@@ -1184,19 +1202,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
@@ -1220,37 +1229,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
@@ -1280,6 +1258,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
@@ -1391,6 +1395,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
@@ -1803,7 +1808,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) |
@@ -1819,8 +1824,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) |
@@ -1829,6 +1833,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) |
@@ -1837,27 +1850,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) |
@@ -1871,7 +1876,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/client-modules.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/client-modules.md
-client-modules.md: 8dae05be292d8d5628c59b768d84dfd97dec1ef2
-client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6
+client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc
+client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72

+ 12 - 12
docs/subsystems/client-modules.md

@@ -2,13 +2,13 @@
 
 English | [中文](client-modules.zh.md)
 
-The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves content-addressed startup batches and individual HMR scripts under `/plugins`, and answers every index-injection collection with the boot protocol rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
+The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves versioned one-or-more-resource combo scripts under `/plugins`, and answers every index-injection collection with the boot protocol rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
 
 Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
 
 ## The wire
 
-The graph is the wire single source between the Node and browser halves. The host composes `WebBootEntry` rows and `WebBootBatch` descriptors from scanned packages, then contributes the registration facade, application preload, bootstrap script, and graph global to the structured index-injection table before the Vite entry. The `global` row renders as `globalThis["__DSH_BOOT__"]` with `<` escaped so plugin-controlled strings cannot break out of the script element. A page without a valid manifest cannot boot: the browser parser rejects malformed rows or batches, duplicate phase names, unknown members, and entries without exactly one initial batch.
+The graph is the wire single source between the Node and browser halves. The host composes `WebBootEntry` rows and `WebBootBatch` descriptors from scanned packages, then contributes the registration facade, application preloads, bootstrap scripts, and graph global to the structured index-injection table before the Vite entry. The `global` row renders as `globalThis["__DSH_BOOT__"]` with `<` escaped so plugin-controlled strings cannot break out of the script element. A page without a valid manifest cannot boot: the browser parser rejects malformed rows or batches, unknown members, and entries without exactly one initial combo descriptor.
 
 ```ts type-equiv
 /**
@@ -22,9 +22,9 @@ The graph is the wire single source between the Node and browser halves. The hos
 interface WebBootEntry {
   /** Entry name == package name. */
   id: string
-  /** Revisioned individual endpoint used by HMR. */
+  /** Revisioned single-resource combo endpoint used by HMR. */
   url: string
-  /** Opaque individual-artifact revision used for HMR cache busting. */
+  /** Opaque plugin-artifact revision used for HMR cache busting. */
   rev: string
   /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
@@ -36,18 +36,18 @@ interface WebBootEntry {
 ```
 
 ```ts type-equiv
-/** Initial script-delivery phase for one content-addressed bundle batch. */
+/** Initial scheduling phase for one content-addressed combo script. */
 type WebBootBatchPhase = 'bootstrap' | 'application'
 ```
 
 ```ts type-equiv
-/** One initial-load script containing the factory registrations for several graph rows. */
+/** One initial combo script; a scheduling phase may span several descriptors. */
 interface WebBootBatch {
-  /** Parser-blocking bootstrap or preloaded application delivery. */
+  /** Parser-blocking bootstrap or preloaded application scheduling. */
   phase: WebBootBatchPhase
-  /** Content-addressed batch script endpoint. */
+  /** Content-addressed combo script endpoint. */
   url: string
-  /** Hash over the batch script and indexed source map. */
+  /** Revision over the combined plugin script bytes and indexed source map. */
   rev: string
   /** Graph entry ids whose factories the script registers, in execution order. */
   entries: string[]
@@ -65,12 +65,12 @@ interface WebBootGraph {
    * unrelated and remains owned by fiber service waiting.
    */
   entries: WebBootEntry[]
-  /** Initial-load batches; every entry belongs to exactly one batch. */
+  /** Initial combo descriptors; every entry belongs to exactly one descriptor. */
   batches: WebBootBatch[]
 }
 ```
 
-Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
+Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and revision; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB. Startup combo revisions hash the combined plugin script bytes and indexed source map, and the graph revision hashes both rows and descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
 
 ## The scan
 
@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
 
 ## The bundle route and index injection
 
-`GET`/`HEAD /plugins/_batch/<phase>/<rev>/client.js` serves the generated startup scripts, with indexed maps beside them. `GET`/`HEAD /plugins/<id>/client.js?rev=<rev>` serves the snapshotted individual artifact for HMR and stamps the same revision onto its map request. All versioned responses use long-lived immutable caching. Unknown paths, absent maps, missing revisions, and stale revisions answer 404 rather than serving current bytes under an old URL or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the generated bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
 
 ## The service
 

+ 12 - 12
docs/subsystems/client-modules.zh.md

@@ -2,13 +2,13 @@
 
 [English](client-modules.md) | 中文
 
-Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 下提供按内容寻址的启动批次与 HMR 独立脚本,并以启动协议行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
+Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules`(`ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins` 下提供带版本的单资源或多资源 combo 脚本,并以启动协议行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.zh.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.zh.md)中,不在本页。
 
 源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
 
 ## wire
 
-图是 Node 半与浏览器半之间协议层的唯一真源。宿主从扫描到的包组合出 `WebBootEntry` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、重复 phase 名、未知成员,以及未恰好归属一个初始批次的 entry。
+图是 Node 半与浏览器半之间协议层的唯一真源。宿主从扫描到的包组合出 `WebBootEntry` 行与 `WebBootBatch` 描述,随后在 Vite entry 之前向结构化 index 注入表贡献 registration facade、application preload、bootstrap 脚本与图全局量。`global` 行渲染为 `globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素。没有有效 manifest 的页面无法启动:浏览器解析器会拒绝畸形 row 或批次、未知成员,以及未恰好归属一个初始 combo 描述的 entry。
 
 ```ts type-equiv
 /**
@@ -22,9 +22,9 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 
 interface WebBootEntry {
   /** Entry name == package name. */
   id: string
-  /** Revisioned individual endpoint used by HMR. */
+  /** Revisioned single-resource combo endpoint used by HMR. */
   url: string
-  /** Opaque individual-artifact revision used for HMR cache busting. */
+  /** Opaque plugin-artifact revision used for HMR cache busting. */
   rev: string
   /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
@@ -36,18 +36,18 @@ interface WebBootEntry {
 ```
 
 ```ts type-equiv
-/** Initial script-delivery phase for one content-addressed bundle batch. */
+/** Initial scheduling phase for one content-addressed combo script. */
 type WebBootBatchPhase = 'bootstrap' | 'application'
 ```
 
 ```ts type-equiv
-/** One initial-load script containing the factory registrations for several graph rows. */
+/** One initial combo script; a scheduling phase may span several descriptors. */
 interface WebBootBatch {
-  /** Parser-blocking bootstrap or preloaded application delivery. */
+  /** Parser-blocking bootstrap or preloaded application scheduling. */
   phase: WebBootBatchPhase
-  /** Content-addressed batch script endpoint. */
+  /** Content-addressed combo script endpoint. */
   url: string
-  /** Hash over the batch script and indexed source map. */
+  /** Revision over the combined plugin script bytes and indexed source map. */
   rev: string
   /** Graph entry ids whose factories the script registers, in execution order. */
   entries: string[]
@@ -65,12 +65,12 @@ interface WebBootGraph {
    * unrelated and remains owned by fiber service waiting.
    */
   entries: WebBootEntry[]
-  /** Initial-load batches; every entry belongs to exactly one batch. */
+  /** Initial combo descriptors; every entry belongs to exactly one descriptor. */
   batches: WebBootBatch[]
 }
 ```
 
-每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输
+每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。初始描述把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条描述。URL 只含有序 package 资源列表与 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分。启动 combo revision 对合并后的插件脚本字节与 indexed sourcemap 求哈希,图 revision 则对 row 与描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载
 
 ## 扫描
 
@@ -82,7 +82,7 @@ interface WebBootGraph {
 
 ## bundle 路由与 index 注入
 
-`GET`/`HEAD /plugins/_batch/<phase>/<rev>/client.js` 提供生成的启动脚本,并在相邻路径提供 indexed map。`GET`/`HEAD /plugins/<id>/client.js?rev=<rev>` 为 HMR 提供已快照的独立产物,并把同一 revision 写入其 map 请求。所有版本化响应都使用长期 immutable 缓存。未知路径、缺失 map、缺少 revision 及陈旧 revision 都返回 404,绝不在旧 URL 下提供当前字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是构建后 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
 
 ## 服务
 

+ 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。
 

+ 2 - 2
docs/subsystems/web-server.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-server.md
-web-server.md: c23a48cd57aeb2127107c0487cb46c9202d11605
-web-server.zh.md: 4097e26ce826067a92de7ba3ec65f790dd6ad1dd
+web-server.md: 9e1e88d6c796e457fa6c185c1927b46cca9fcf52
+web-server.zh.md: 4401ccf628360a9571e77ea14ae6c29bd22af151

+ 10 - 4
docs/subsystems/web-server.md

@@ -2,7 +2,7 @@
 
 English | [中文](web-server.zh.md)
 
-[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
+[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
 
 Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
 
@@ -29,20 +29,26 @@ Match order is fixed: exact table first, then longest matching prefix, then the
 ## Config
 
 ```ts type-equiv
-/** Gateway config: the listen address. */
+/** Web server listen and response-compression config. */
 interface Config {
   /** Listen host; the two supported values are loopback and all-interfaces. */
   host: '127.0.0.1' | '0.0.0.0'
   /** Listen port; zero requests an OS-assigned port. */
   port: number
+  /** Response compression for socket-backed HTTP requests. @default 'none' */
+  compression?: 'none' | 'gzip'
+  /** Gzip DEFLATE level from 0 through 9. @default 1 */
+  compressionLevel?: number
+  /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
+  compressionThresholdBytes?: number
 }
 ```
 
-`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. The dist location is an assembly fact of the frontend plugin that claims the seat.
+`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat.
 
 ## The service
 
-`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
+`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
 
 A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
 

+ 10 - 4
docs/subsystems/web-server.zh.md

@@ -2,7 +2,7 @@
 
 [English](web-server.md) | 中文
 
-[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
+[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
 
 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
 
@@ -29,20 +29,26 @@ interface WebRoute {
 ## 配置
 
 ```ts type-equiv
-/** Gateway config: the listen address. */
+/** Web server listen and response-compression config. */
 interface Config {
   /** Listen host; the two supported values are loopback and all-interfaces. */
   host: '127.0.0.1' | '0.0.0.0'
   /** Listen port; zero requests an OS-assigned port. */
   port: number
+  /** Response compression for socket-backed HTTP requests. @default 'none' */
+  compression?: 'none' | 'gzip'
+  /** Gzip DEFLATE level from 0 through 9. @default 1 */
+  compressionLevel?: number
+  /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
+  compressionThresholdBytes?: number
 }
 ```
 
-`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。dist 位置是认领席位的前端插件的组装事实。
+`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。
 
 ## 服务
 
-`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
+`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
 
 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
 

+ 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')
-  })
 })

Неке датотеке нису приказане због велике количине промена