Просмотр исходного кода

Merge pull request #1802 from deepseek-harness/worktree-subagent-pref

feat: subagent list use preparation + projection
imccyu 1 месяц назад
Родитель
Сommit
5c3d2a1706
63 измененных файлов с 1894 добавлено и 579 удалено
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml
  2. 167 0
      .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md
  3. 184 0
      .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml
  5. 7 4
      .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
  6. 7 4
      .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md
  7. 2 2
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
  8. 4 0
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
  9. 4 0
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
  10. 3 0
      apps/cli/composition.md
  11. 24 14
      docs/cordis-catalog/services.md
  12. 2 2
      docs/core-data-structures/subagent.i18n.yaml
  13. 2 2
      docs/core-data-structures/subagent.md
  14. 2 2
      docs/core-data-structures/subagent.zh.md
  15. 3 4
      docs/module-graph.md
  16. 2 2
      docs/tool-catalog.md
  17. 3 0
      examples/acp-agent/composition.md
  18. 8 2
      examples/acp-agent/cordis.yml
  19. 3 0
      examples/headless-agent/composition.md
  20. 5 0
      examples/headless-agent/cordis.yml
  21. 44 0
      examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml
  22. 26 0
      examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts
  23. 31 0
      examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl
  24. 1 0
      examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json
  25. 119 0
      examples/headless-agent/tests/subagent-diagnostic.snapshot.ts
  26. 1 0
      examples/package.json
  27. 1 0
      knip.json
  28. 6 0
      packages/bundle/base/cordis.patch.yml
  29. 1 0
      packages/bundle/base/package.json
  30. 2 2
      packages/bundle/web-app/README.i18n.yaml
  31. 1 1
      packages/bundle/web-app/README.md
  32. 1 1
      packages/bundle/web-app/README.zh.md
  33. 0 3
      packages/bundle/web-app/cordis.patch.yml
  34. 0 1
      packages/bundle/web-app/package.json
  35. 1 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  36. 95 62
      packages/host/apiproxy/src/api-proxy.ts
  37. 2 1
      packages/host/apiproxy/src/api/subagents.ts
  38. 42 0
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  39. 109 23
      packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
  40. 19 1
      packages/session-query/session-query/tests/session-query.spec.ts
  41. 2 2
      packages/subagent/subagent/README.i18n.yaml
  42. 2 2
      packages/subagent/subagent/README.md
  43. 2 2
      packages/subagent/subagent/README.zh.md
  44. 6 4
      packages/subagent/subagent/package.json
  45. 1 1
      packages/subagent/subagent/src/client.ts
  46. 29 18
      packages/subagent/subagent/src/index.ts
  47. 254 156
      packages/subagent/subagent/src/list-children.ts
  48. 39 0
      packages/subagent/subagent/src/projection-types.ts
  49. 76 2
      packages/subagent/subagent/src/projection.ts
  50. 495 200
      packages/subagent/subagent/tests/list-children.spec.ts
  51. 0 13
      packages/subagent/subagent/tests/optional-session-query.spec.ts
  52. 8 4
      packages/subagent/subagent/tests/timing-projection.spec.ts
  53. 2 2
      packages/subagent/subagent/tsconfig.json
  54. 2 2
      packages/subagent/tool-subagent-control/README.i18n.yaml
  55. 1 1
      packages/subagent/tool-subagent-control/README.md
  56. 1 1
      packages/subagent/tool-subagent-control/README.zh.md
  57. 1 7
      packages/subagent/tool-subagent-control/package.json
  58. 5 8
      packages/subagent/tool-subagent-control/src/list-agents.ts
  59. 4 5
      packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
  60. 2 0
      packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
  61. 0 3
      packages/subagent/tool-subagent-control/tsconfig.json
  62. 16 7
      pnpm-lock.yaml
  63. 4 3
      scripts/gen-tool-catalog.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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-06-subagent-list-identity-projection.md
+2026-08-06-subagent-list-identity-projection.md: ba023d04805ff3335c8f243510aa8ddc15fe13d6
+2026-08-06-subagent-list-identity-projection.zh.md: 368a70f5b3e5a27e4e1c648e8819476d40a57709

Разница между файлами не показана из-за своего большого размера
+ 167 - 0
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md


+ 184 - 0
.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md

@@ -0,0 +1,184 @@
+# Agent Note: subagent 列表经投影单元读取身份
+
+Status: implemented
+
+[English](2026-08-06-subagent-list-identity-projection.md) | 中文
+
+## 问题
+
+重写前的 `SubagentService.listChildren` 对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。
+
+同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。
+
+根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。
+
+## 决策
+
+mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。
+
+消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。
+
+要点:
+
+- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。
+- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。
+- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。
+- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。
+
+与既有记录的关系:
+
+- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。
+- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。
+
+### `subagent` projection unit
+
+挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`:
+
+```ts ignore-check
+export type SubagentIdentityProjection =
+  | { mode: 'one-shot'; label?: string; seq: number }
+  | { mode: 'continuable'; label: string; seq: number }
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
+    subagent: SubagentIdentityProjection | null
+  }
+}
+```
+
+- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
+- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强契约完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。
+- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 契约版本失配失效、落权威重折。
+- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。
+
+### 枚举:subagent 自管 live-preferred 合并
+
+`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))的枚举不经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实:
+
+- 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。
+- `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。
+- `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。
+- 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。
+- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。)
+- persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。
+
+### 取值:三级"算完即止"阶梯
+
+对每个枚举出的 child,mode/label 取值走三级阶梯——算完即止,不自建缓存、无回写(第三级与 apiproxy `session.history` 的冷读同款):
+
+| 级 | 读法 | 成本 |
+| --- | --- | --- |
+| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 |
+| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 |
+| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 |
+
+- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
+- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。
+- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
+- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。
+- 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。
+- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。
+- 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。
+
+### 权威模型
+
+- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。
+- Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。
+- 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。
+
+### `listChildren` 行形状与消费面
+
+`SubagentListEntry` **数据结构与重写前完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身零事件解析。"没有就等待硬读取"保证阶梯对健康数据必然算得出 mode/label。
+
+```ts ignore-check
+export type SubagentListEntry =
+  | ({
+    readonly kind: 'child'
+    readonly id: SessionId
+    readonly activity: 'running' | 'inactive'
+    readonly hasChildren: boolean
+  } & (
+    | { readonly mode: 'one-shot'; readonly label?: string }
+    | { readonly mode: 'continuable'; readonly label: string }
+  ))
+  | {
+    readonly kind: 'diagnostic'
+    readonly id: SessionId
+    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
+  }
+```
+
+对每个枚举出的 child,阶梯取值结果按四态映射成行:
+
+| 阶梯取值结果 | 行 |
+| --- | --- |
+| 快照含非 null 的 `subagent` 身份 | child 行 |
+| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) |
+| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) |
+| cold 整读失败 | diagnostic 行,reason `unavailable` |
+
+- `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。
+- descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。
+- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是"尚无数据",fold 抛错是"数据坏了"——running 的中毒 child 也出 `corrupt` 行而非 omit。
+
+已知边界偏差(有意接受,随本记录留档):
+
+- 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。
+- own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。
+- live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。
+- 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。
+- 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。
+- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反 establishing provider"恰追加一次"契约,属损坏类数据,与多描述符偏差同族同源;②需"损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)"双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按"cache 永不为权威"总纲归档为接受项。
+
+消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。
+
+### 改动落点
+
+| 区域 | 文件 | 改动 |
+| --- | --- | --- |
+| subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 |
+| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) |
+| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 |
+| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 |
+| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 |
+| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** |
+
+## 考虑过的替代方案
+
+**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是"第一次列表一次 `inspect` 现算",不碰持久格式。
+
+**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。
+
+**给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。
+
+**list 行 mode/label 可选化(v4 一稿)。** 健康数据必然可算;可选化只是把垃圾数据的处理复杂度外溢给全部消费方——每个消费面都要长出过滤分支和 unknown 展示态。强契约加算不出即 omit 更干净。
+
+**彻底删除 diagnostic 行(v5 一稿)。** 删除把库损坏的可见性外溢为行静默消失,wire/tool/GUI 反要各自承担契约与快照变更;而保留只需列表侧按投影值缺席与 activity 派生分类,零成本。库里的坏、死子会话必须可见是 diagnostic 存在的原始动机,保留后消费面整体零改动。
+
+**registry 计算失败通道(per-unit 容错加 `failures` 附加字段)。** 为把损坏、版本不认识报告给消费方,曾考虑让 registry 捕获 unit 异常并在 snapshot 旁附 per-key 失败态。被否:failure 不是值,也不必是通道——unit 永不抛错,缺席本身就是信号,"大不了算出来没有",如何呈现是消费方要考虑的事。该路线讨论顺带留下一个独立观察:vendor cordis 的 `emit`([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts))对 listener 抛错零捕获,投影驱动挂在 `session/event` 上时 unit 异常会沿 emit 逃逸——这加重了"unit 永不抛错"纪律的分量,但 emit 容错的修复不属于本记录范围。
+
+**值随 query 索引 preparation 落库(v4/v5 定稿,一度施工)。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志;`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump 均已施工过。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。
+
+**subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。
+
+**session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。
+
+## 验证
+
+`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。
+
+## 后果
+
+- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
+- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。
+- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
+- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。
+- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。
+- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。
+
+## 相关
+
+- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。
+- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。
+- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。
+- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
-2026-07-22-durable-subagent-catalog-and-list-agents.md: 0dd7eebac74689004014248c7178dba540ef4662
-2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 33b0296cf9914d1975fb1dc84564b498a09bd511
+2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009
+2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2

+ 7 - 4
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md

@@ -12,6 +12,8 @@ Enumeration must cross-check immutable session lineage, descriptor validity, and
 
 ## Decision
 
+**Superseded read path.** [Subagent list identity via the projection unit](../architecture/2026-08-06-subagent-list-identity-projection.md) replaces this note's enumeration and per-child read design: `listChildren` now merges the live session store with optional session persistence directly and serves each child's mode/label from the registered `subagent` projection unit — no session-query dependency, no list-time descriptor scan — and that note owns the current listing semantics, including the diagnostic mapping. This note remains the authority for descriptor persistence, the mode-discriminated descriptor as durable identity, direct-parent authorization, and the model-facing `list_agents` projection; the trace-based read mechanics below are decision context, not current behavior.
+
 Parent-to-child enumeration is a service capability with consumer-specific projections. `SubagentService.listChildren(parentSessionId: SessionId)` ([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts)) does the following:
 
 - use `ctx.sessionQuery.traceSession(parentSessionId)` to obtain the parent's direct live-preferred child sessions;
@@ -33,7 +35,7 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for
 
 The published logical record is also the activity source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `inactive`. Activity comes directly from the trace and causes no additional child-log load. `inactive` encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which `send_message` can materialize another Activation. Conversely, `running` says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation.
 
-The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract.
+The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`) and the narrowed load requirement live in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md).
 
 `listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing.
 
@@ -91,9 +93,10 @@ The first version has no child deletion operation. If later product behavior del
 ## Testing
 
 - `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
-- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. `packages/subagent/subagent/tests/optional-session-query.spec.ts` rejects eager evaluation of the optional runtime while importing the ordinary subagent surface.
-- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal.
-- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, session query, and JSONL persistence, rendering `<id> [complete] — <label>`.
+- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; and typed stable error codes. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
+- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, the narrowed load requirement without `sessionQuery`, and HMR disposal.
+- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [complete] — <label>`.
+- The keyless snapshot scenario `subagent-diagnostic` (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic.
 - The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result.
 
 ## Consequences

+ 7 - 4
.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 ## 决策
 
+**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。
+
 parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentService.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作:
 
 - 使用 `ctx.sessionQuery.traceSession(parentSessionId)` 获取 parent 的直接且实时优先的 child 会话;
@@ -33,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
 
 已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
 
-subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。
+subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准。
 
 `listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。
 
@@ -91,9 +93,10 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
 ## 测试
 
 - `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
-- `packages/subagent/subagent/tests/list-children.spec.ts` 先固定一个只有会话、`subagents` 和 `sessionQuery` 而没有 `agents` 的纯查询组合,再以无密钥方式驱动完整真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):来自同一真实追踪的一次性与可继续 child;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `inactive` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;转发 trace/精确读取取消并稳定归一化为 `CANCELLED`;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。`packages/subagent/subagent/tests/optional-session-query.spec.ts` 会在导入普通 subagent surface 时拒绝对可选运行时的 eager 求值。
-- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、加载时的 `sessionQuery` 注入,以及 HMR dispose。
-- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、会话查询和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。
+- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;以及带类型的稳定错误码。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
+- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、收窄后的加载要求(不再注入 `sessionQuery`),以及 HMR dispose。
+- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。
+- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
 - 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。
 
 ## 影响

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
-2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
-2026-07-27-session-projection-and-command-log.zh.md: a22ebe57811339a0e583ae00909e60482ddb57b1
+2026-07-27-session-projection-and-command-log.md: 789e79f2ecab1a9f3ac717df86059150ed2d4da9
+2026-07-27-session-projection-and-command-log.zh.md: 4d680b37f5d49a243447542706c8b7ced8d80e2a

+ 4 - 0
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md

@@ -56,6 +56,10 @@ declare module 'cordis' {
 - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
 - The package owns `./invariant` (every served key has a live registration).
 
+### Shipped consumer: the subagent identity unit
+
+The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentService.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision.
+
 ### Wire: projections block on the history tail page
 
 ```ts ignore-check

+ 4 - 0
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md

@@ -56,6 +56,10 @@ declare module 'cordis' {
 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
 
+### 已交付的消费方:subagent 身份单元
+
+注册表的两处读面已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 以 last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 经一次持久化检查上的 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读面——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
+
 ### 协议层:历史尾页上的 projections 块
 
 ```ts ignore-check

+ 3 - 0
apps/cli/composition.md

@@ -40,6 +40,8 @@ flowchart LR
   cfg --> plugin_dsh_base_session_persistence_jsonl
   plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
   cfg --> plugin_dsh_base_session_query_sqlite
+  plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
+  cfg --> plugin_dsh_base_session_projection
   plugin_dsh_base_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
   cfg --> plugin_dsh_base_telemetry_otel
   plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
@@ -166,6 +168,7 @@ flowchart LR
 | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
 | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
 | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
+| `session-projection` | `@deepseek-ai/dsh-session-projection` |
 | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |

+ 24 - 14
docs/cordis-catalog/services.md

@@ -2099,22 +2099,32 @@ registerContinuableSetup(contribution: ContinuableSetupContribution): () => void
 async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
 
 /**
- * Enumerate the parent's direct session-backed subagents from the
- * live-preferred session corpus without loading or resuming an Agent. Session
- * query supplies lineage, candidate order, event reads, and live state; this
- * service interprets descriptor mode, activity, and per-child diagnostics
- * without consulting Agent registrations, Activations, or providers.
+ * Enumerate the parent's direct session-backed subagents without loading or
+ * resuming an Agent and without any query seam: 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.
  *
- * The trace and exact descriptor read receive `signal`; the full event-list
- * read has no signal parameter, so the scan rechecks cancellation around
- * every await and between candidates. Query rejections that settle after an
- * abort become a stable `SubagentError` with code `CANCELLED`.
+ * Every persistence read receives `signal`, and the listing rechecks
+ * cancellation around each of those awaits. 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 where supported and
- *   observed around every query await.
- * @returns children and per-child diagnostics in stable trace order.
- * @throws {@link SubagentError} when session query is unavailable or the
- *   caller cancels the scan.
+ * @param signal - caller-owned cancellation forwarded to persistence reads
+ *   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
+ *   store is not mounted, or the caller cancels the listing.
  */
 listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
 

+ 2 - 2
docs/core-data-structures/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/core-data-structures/subagent.md
-subagent.md: 315051fafaa0bb291a0f7525d2de142d8570961b
-subagent.zh.md: 5e147b85b1b9a57fb604145bef66e560c443c1de
+subagent.md: 4d7552eb5749284d90e31e62d8ac02e3d7a21b1d
+subagent.zh.md: ebcb1ce445c164352109d6613028c7a36c10d6d4

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
docs/core-data-structures/subagent.md


Разница между файлами не показана из-за своего большого размера
+ 2 - 2
docs/core-data-structures/subagent.zh.md


+ 3 - 4
docs/module-graph.md

@@ -757,7 +757,7 @@ flowchart TD
   pkg_subagent --> pkg_session
   pkg_subagent --> pkg_session_persistence
   pkg_subagent --> pkg_session_projection
-  pkg_subagent --> pkg_session_query
+  pkg_subagent --> pkg_session_projection_cache
   pkg_subagent --> pkg_tasks
   pkg_subagent --> pkg_tools
   pkg_tool_web --> pkg_invariants
@@ -955,7 +955,6 @@ flowchart TD
   pkg_tool_subagent_control --> pkg_invariants
   pkg_tool_subagent_control --> pkg_llm
   pkg_tool_subagent_control --> pkg_session
-  pkg_tool_subagent_control --> pkg_session_query
   pkg_tool_subagent_control --> pkg_subagent
   pkg_tool_subagent_control --> pkg_tools
   pkg_tool_subagent_report --> pkg_invariants
@@ -1245,7 +1244,7 @@ flowchart TD
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
 | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
 | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
@@ -1277,7 +1276,7 @@ flowchart TD
 | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
-| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
+| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/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/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
 | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |

+ 2 - 2
docs/tool-catalog.md

@@ -32,7 +32,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
 | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
 | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. |
-| `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionQuery (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). |
+| `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionProjections (list_agents catalog rows)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry). |
 | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. |
 | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. |
@@ -1232,7 +1232,7 @@ Send a message to a background subagent by its subagent id, continuing the same
 
 Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)
 
-The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).
+The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).
 
 ## `@deepseek-ai/dsh-tool-subagent-report`
 

+ 3 - 0
examples/acp-agent/composition.md

@@ -33,6 +33,8 @@ flowchart LR
   cfg --> plugin_acp_token_meter
   plugin_acp_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
   cfg --> plugin_acp_compact_basic
+  plugin_acp_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
+  cfg --> plugin_acp_session_projection
   plugin_acp_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
   cfg --> plugin_acp_subagent
   plugin_acp_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
@@ -82,6 +84,7 @@ flowchart LR
 | `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
 | `token-meter` | `@deepseek-ai/dsh-token-meter` |
 | `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
+| `session-projection` | `@deepseek-ai/dsh-session-projection` |
 | `subagent` | `@deepseek-ai/dsh-subagent` |
 | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
 | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |

+ 8 - 2
examples/acp-agent/cordis.yml

@@ -80,6 +80,12 @@
     maxTokens: 8192
     compactionRetries: 1
 
+# Projection registry: subagent catalog identity (mode/label) folds through
+# its registered units; the catalog surfaces (`list_agents`, subagent listing)
+# fail loud without the capability.
+- id: session-projection
+  name: '@deepseek-ai/dsh-session-projection'
+
 # Expose fresh-child `spawn` and completed-prefix `fork` through separate tool
 # names so multi-child scenarios exercise both transports. These leaves follow
 # the app because it provides `ctx.agents` and `ctx.tools`.
@@ -98,8 +104,8 @@
 
 # Continuable background children are selected per delegation tool. The
 # separately loaded control package registers the global `send_message`; its
-# list plugin registers `list_agents` and requires the app's session query.
-# `report` is installed only in continuable child scopes.
+# list plugin registers `list_agents`, served through the sessionProjections
+# registry mounted above. `report` is installed only in continuable child scopes.
 - id: tool-subagent-control
   name: '@deepseek-ai/dsh-tool-subagent-control'
 

+ 3 - 0
examples/headless-agent/composition.md

@@ -31,6 +31,8 @@ flowchart LR
   cfg --> plugin_headless_token_meter
   plugin_headless_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
   cfg --> plugin_headless_compact_basic
+  plugin_headless_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
+  cfg --> plugin_headless_session_projection
   plugin_headless_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
   cfg --> plugin_headless_subagent
   plugin_headless_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
@@ -71,6 +73,7 @@ flowchart LR
 | `cli-agent` | `@deepseek-ai/dsh-cli-demo` |
 | `token-meter` | `@deepseek-ai/dsh-token-meter` |
 | `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
+| `session-projection` | `@deepseek-ai/dsh-session-projection` |
 | `subagent` | `@deepseek-ai/dsh-subagent` |
 | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
 | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |

+ 5 - 0
examples/headless-agent/cordis.yml

@@ -71,6 +71,11 @@
     maxTokens: 8192
     compactionRetries: 1
 
+# Projection registry: durable subagent identity (mode/label) folds through
+# its registered units; subagent catalog reads fail loud without the capability.
+- id: session-projection
+  name: '@deepseek-ai/dsh-session-projection'
+
 # Expose fresh-child `spawn` and completed-prefix `fork` through independent
 # in-process backends.
 - id: subagent

+ 44 - 0
examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml

@@ -0,0 +1,44 @@
+# Keyless real-Loader composition for the descriptor-less cold-child
+# diagnostic snapshot. The seeded parent owns one session-backed child whose
+# log carries `origin: 'subagent'` but no descriptor event, so the projection
+# fold produces no identity and `list_agents` must surface the child as a
+# `[diagnostic: corrupt]` row instead of silently dropping it.
+
+- id: persistence
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: './.sessions'
+    compression: none
+
+# file/override both default to their DSH_SNAPSHOT_* env vars.
+- id: replay
+  name: '@deepseek-ai/dsh-llm-replay'
+
+# This scenario probes the subagent catalog only, so the bash/filesystem
+# stacks are absent; the bundle must opt out of the tools that would wait
+# forever for executors this tree never mounts.
+- id: agent
+  name: '@deepseek-ai/dsh-agent-spine-demo'
+  config:
+    agents: []
+    workspaceContext: false
+    skills:
+      enabled: false
+    toolBash: false
+    toolTasks: false
+    goals: false
+
+# Projection registry: the cold child's identity fold runs through it; the
+# catalog read fails loud when the capability is absent.
+- id: session-projection
+  name: '@deepseek-ai/dsh-session-projection'
+
+- id: subagent
+  name: '@deepseek-ai/dsh-subagent'
+
+- id: tool-subagent-list-agents
+  name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
+
+# Await the persisted resume before the headless driver inspects root agents.
+- id: resumed-agent
+  name: './tests/fixtures/subagent-diagnostic-agent.ts'

+ 26 - 0
examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts

@@ -0,0 +1,26 @@
+/**
+ * Loader fixture that resumes the seeded diagnostic-scenario parent before
+ * CLI dispatch, so `list_agents` runs against its pre-seeded cold child.
+ * @module subagent-diagnostic-agent
+ */
+
+import type { Context } from 'cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+
+/** Fixture plugin name. */
+export const name = 'subagent-diagnostic-agent'
+/** Services that must exist before the fixture resumes its agent. */
+export const inject = ['agents', 'agentLoop', 'sessionPersistence']
+
+/**
+ * Resume the seeded session and bind its exact handle to this fixture's lifetime.
+ * @param ctx - settled agent and persistence services from the Loader tree.
+ * @returns after the resumed agent is published.
+ */
+export async function apply(ctx: Context): Promise<void> {
+  const handle = await ctx.agents.resume({
+    resumeSessionId: 'subagent-diagnostic-parent' as SessionId,
+    agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+  })
+  ctx.effect(() => () => handle.dispose(), 'subagent-diagnostic-agent.handle')
+}

+ 31 - 0
examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl

@@ -0,0 +1,31 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}}
+{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a background task."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"turn/end","seq":2,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
+{"type":"session/end-seed","seq":3,"time":0,"data":{}}
+{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}
+{"type":"turn/start","seq":5,"time":0,"data":{"turn":2}}
+{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}}
+{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"session/title","seq":9,"time":0,"data":{"title":"Start a background task.","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"list-once","name":"list_agents","argumentsDelta":"{}"}}}
+{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}}}
+{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
+{"type":"tool/call","seq":18,"time":0,"data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}}
+{"type":"tool/result","seq":19,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
+{"type":"step/end","seq":20,"time":0,"data":{"turn":2,"step":1}}
+{"type":"step/start","seq":21,"time":0,"data":{"turn":2,"step":2}}
+{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The stored subagent is unreadable. PARENT_DONE"}}}
+{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}}}
+{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}
+{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":2}}
+{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}

+ 1 - 0
examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json

@@ -0,0 +1 @@
+[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The stored subagent is unreadable. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The stored subagent is unreadable. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]

+ 119 - 0
examples/headless-agent/tests/subagent-diagnostic.snapshot.ts

@@ -0,0 +1,119 @@
+/**
+ * Assembled-app regression: a persisted `origin: 'subagent'` child whose log
+ * carries no descriptor event is surfaced by `list_agents` as a
+ * `[diagnostic: corrupt]` row instead of being silently dropped.
+ */
+
+import { readFile, readdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { Context } from 'cordis'
+import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import { describe, expect, it } from 'vitest'
+
+const fixtureDir = fileURLToPath(new URL('./subagent-diagnostic-snapshots/descriptorless-child', import.meta.url))
+const replayOverride = join(fixtureDir, 'replay.override.json')
+const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
+const configPath = fileURLToPath(new URL('../subagent-diagnostic.cordis.snapshot.yml', import.meta.url))
+const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+const parentId = SessionId('subagent-diagnostic-parent')
+const childId = SessionId('subagent-diagnostic-child')
+const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
+const task = 'Call list_agents once and report what it shows.'
+
+/**
+ * Seed a completed parent turn plus one cold child that durably classifies
+ * as a subagent (`origin`) but never appended its descriptor event — the
+ * publication-window death the diagnostic row exists for.
+ */
+async function seedDescriptorlessChild(root: string, cwd: string): Promise<void> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
+  const parentMeta: SessionHeader = {
+    version: SESSION_FORMAT_VERSION,
+    id: parentId,
+    createdAt: 1,
+    cwd,
+    delegationDepth: 0,
+  }
+  const parentEvents: SessionEvent[] = [
+    { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
+    { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background task.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
+    { type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } } },
+  ]
+  const childMeta: SessionHeader = {
+    version: SESSION_FORMAT_VERSION,
+    id: childId,
+    createdAt: 2,
+    cwd,
+    parentSession: parentId,
+    origin: 'subagent',
+    delegationDepth: 1,
+  }
+  const childEvents: SessionEvent[] = [
+    { type: 'turn/start', seq: 0, time: 20, data: { turn: 1 } },
+    { type: 'turn/end', seq: 1, time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } },
+  ]
+  try {
+    await ctx.sessionPersistence.create(parentMeta)
+    await ctx.sessionPersistence.append(parentId, parentEvents)
+    await ctx.sessionPersistence.create(childMeta)
+    await ctx.sessionPersistence.append(childId, childEvents)
+  } finally {
+    await ctx.fiber.dispose()
+  }
+}
+
+describe('descriptor-less cold child diagnostic snapshot', () => {
+  it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => {
+    let cwd = ''
+    const result = await runLoaderSmoke({
+      label: 'subagent diagnostic headless stream-json snapshot',
+      tempDirPrefix: 'dsh-subagent-diag-',
+      binScript,
+      configPath,
+      binArgs: ['--config', configPath, '--output-format', 'stream-json', task],
+      tsconfigPath,
+      env: {
+        DSH_SNAPSHOT_FILE: replayOverride,
+        DSH_SNAPSHOT_OVERRIDE: replayOverride,
+      },
+      prepare: async (runCwd) => {
+        cwd = runCwd
+        await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd)
+      },
+      inspect: async (runCwd) => {
+        const sessionsDir = join(runCwd, '.sessions')
+        const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl'))
+        const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8')))
+        const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"'))
+        if (parent === undefined) throw new Error('missing persisted parent log')
+
+        // THE model-visible fact: the descriptor-less child is reported, not
+        // silently dropped, and its reason is the corrupt classification.
+        expect(parent).toContain(`${childId} [diagnostic: corrupt]`)
+
+        const context: NormalizeContext = { sessionIds: [parentId, childId], cwd }
+        const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context))
+        if (refreshing) {
+          await writeFile(parentExpected, normalizedParent)
+        }
+        expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
+      },
+    })
+
+    expect(result.stderr).toBe('')
+    const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
+    expect(records.at(-1)).toMatchObject({
+      type: 'result',
+      sessionId: parentId,
+      output: 'The stored subagent is unreadable. PARENT_DONE',
+    })
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})

+ 1 - 0
examples/package.json

@@ -54,6 +54,7 @@
     "@deepseek-ai/dsh-session": "workspace:*",
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
+    "@deepseek-ai/dsh-session-projection": "workspace:*",
     "@deepseek-ai/dsh-session-query": "workspace:*",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
     "@deepseek-ai/dsh-session-reference": "workspace:*",

+ 1 - 0
knip.json

@@ -36,6 +36,7 @@
       "entry": [
         "headless-agent/tests/fixtures/cli-mock-llm.ts",
         "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts",
+        "headless-agent/tests/fixtures/subagent-diagnostic-agent.ts",
         "headless-agent/tests/fixtures/subagent-inheritance-agent.ts",
         "headless-agent/tests/fixtures/workspace-context-resume-agent.ts",
         "headless-agent/tests/fixtures/goal-domain/seed-goal.ts",

+ 6 - 0
packages/bundle/base/cordis.patch.yml

@@ -100,6 +100,12 @@
         path: ':memory:'
         openAt: first-search
 
+    # Shared projection registry: subagent catalog identity (mode/label) folds
+    # through its registered units, so the `list_agents` surface below fails
+    # loud without it; web layers reuse this same mount for list rows.
+    - id: session-projection
+      name: '@deepseek-ai/dsh-session-projection'
+
     # Session telemetry, on for every dsh mode: mirrors every session-log
     # event (assistant/chunk projected to first-of-step) plus ops markers onto
     # OTLP/HTTP log records, streaming on the batch processor's cadence

+ 1 - 0
packages/bundle/base/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",

+ 2 - 2
packages/bundle/web-app/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
-README.md: a835497982e41c29c5dbc287c43b0515a61f77c9
-README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef
+README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
+README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170

+ 1 - 1
packages/bundle/web-app/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
+The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
 
 ## Model Experience
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
+dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
 
 ## 模型体验
 

+ 0 - 3
packages/bundle/web-app/cordis.patch.yml

@@ -45,9 +45,6 @@
 # `dshClient` rows are the browser roster the modules node half scans into
 # window.__DSH_BOOT__; the modules row is simultaneously a host row.
 - insert:
-    - id: session-projection
-      name: '@deepseek-ai/dsh-session-projection'
-
     - id: code-runtime
       name: '@deepseek-ai/dsh-code-runtime-worker'
 

+ 0 - 1
packages/bundle/web-app/package.json

@@ -63,7 +63,6 @@
     "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
-        jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n *   observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n *   caller cancels the scan.\n */',
+        jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n *   and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n *   store is not mounted, or the caller cancels the listing.\n */',
       },
       {
         signature: 'registerProvider(provider: SubagentProvider): () => void',

+ 95 - 62
packages/host/apiproxy/src/api-proxy.ts

@@ -527,6 +527,28 @@ function detachedProjectionsFor(
   return registry.restore({}, events, 0).snapshot
 }
 
+/**
+ * Best-effort projections for one subagent history page, fail-soft like
+ * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
+ * never blocks transcript reading — the page is served without the block.
+ * @param ctx - context carrying the logger for the degradation warning.
+ * @param childSessionId - the child whose page is being decorated.
+ * @param compute - the arm-specific fold (live watermark or detached restore).
+ * @returns the projections block, or undefined when the fold failed.
+ */
+function subagentHistoryProjections(
+  ctx: Context,
+  childSessionId: SessionId,
+  compute: () => SessionProjectionsBlock | undefined,
+): SessionProjectionsBlock | undefined {
+  try {
+    return compute()
+  } catch (error) {
+    ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
+    return undefined
+  }
+}
+
 /** Map continuation admission failures without exposing provider details. */
 function subagentPromptError(
   request: RpcRequest<{ childSessionId: SessionId }>,
@@ -567,6 +589,15 @@ function subagentPromptError(
   return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
 }
 
+/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
+function projectionsUnavailableError(): RpcError {
+  return {
+    code: 'internal',
+    message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
+    details: {},
+  }
+}
+
 /** Verify one address and mode against the complete direct-child catalog. */
 async function catalogChild(
   ctx: Context,
@@ -600,19 +631,11 @@ async function catalogChild(
     }
     return { entry }
   } catch (error: unknown) {
-    if (signal?.aborted
-      || (error instanceof SubagentError && error.code === 'CANCELLED')
-      || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
+    if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
       return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
     }
-    if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
-      return {
-        error: {
-          code: 'subagent-not-found',
-          message: `parent session "${parentSessionId}" was not found`,
-          details: { parentSessionId, childSessionId },
-        },
-      }
+    if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
+      return { error: projectionsUnavailableError() }
     }
     return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
   }
@@ -925,28 +948,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     })
   }
 
-  /** Whether the session's own suffix carries the durable subagent discriminator. */
-  function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
-    const events = session.events
-    // Indexed scan from the own-suffix start: slicing copies the whole suffix
-    // on every Agent-bound RPC, including each `session.prompt` on long
-    // transcripts.
-    for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
-      if (events[index]?.type === 'subagent/descriptor') return true
-    }
-    return false
-  }
-
   /**
-   * Generic Host interaction cannot claim a durably classified subagent or an
-   * Agent created through its live parent. The runtime-owner arm also covers
-   * descriptor-less child publication windows and older stored headers.
+   * Generic Host interaction cannot claim a durably classified subagent
+   * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
+   * live parent.
    */
   function hasSubagentOwner(
-    session: Pick<Session, 'events' | 'header'>,
+    session: Pick<Session, 'header'>,
     agent: Agent | undefined,
   ): boolean {
-    if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
+    if (session.header.origin === 'subagent') return true
     const parentId = session.header.parentSession
     if (parentId === undefined || agent === undefined) return false
     const parent = ctx.agents.get(parentId)
@@ -1002,7 +1013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       resume = (async () => {
         try {
           const inspected = await inspectServable(sessionId)
-          if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
+          if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
             throw new SubagentSessionOwnership(sessionId)
           }
           const publishedSession = ctx.sessions.get(sessionId)
@@ -1121,7 +1132,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           // Ownership first: explicit-id adoption of a session-backed
           // subagent must answer `agent-busy` regardless of the requested
           // cwd (the api/commands.ts contract), not a cwd conflict.
-          if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
+          if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
             throw new SubagentSessionOwnership(sessionId)
           }
           if (inspected.meta.cwd !== cwd) {
@@ -1903,15 +1914,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
           })
         } catch (error: unknown) {
-          if (signal?.aborted
-            || (error instanceof SubagentError && error.code === 'CANCELLED')
-            || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
+          if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
             return err(request, {
               code: 'cancelled',
               message: 'subagent catalog read was cancelled',
               details: {},
             })
           }
+          if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
+            return err(request, projectionsUnavailableError())
+          }
           return err(request, {
             code: 'internal',
             message: 'subagent catalog read failed',
@@ -1928,44 +1940,65 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           parentSessionId, childSessionId, mode,
         }, signal)
         if (verified.error !== undefined) return err(request, verified.error)
-        try {
-          const snapshot = await ctx.sessionQuery.readSession(childSessionId)
-          signal?.throwIfAborted()
-          if (snapshot.session.parentSession !== parentSessionId) {
-            return err(request, {
-              code: 'subagent-unauthorized',
-              message: 'subagent parent changed during history read',
-              details: { childSessionId },
-            })
-          }
-          const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
-          const projections = beforeSeq === undefined
-            ? detachedProjectionsFor(ctx, snapshot.events)
+        // The generic-history data plane: an attached child serves its
+        // in-memory snapshot and the registry's live watermark projections; a
+        // cold child is one persistence inspection plus a detached fold.
+        let header: SessionHeader
+        let events: SessionEvent[]
+        let projections: SessionProjectionsBlock | undefined
+        const attached = ctx.sessions.get(childSessionId)
+        if (attached !== undefined) {
+          header = attached.header
+          events = [...attached.events]
+          projections = beforeSeq === undefined
+            ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
             : undefined
-          return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
-        } catch (error: unknown) {
-          if (signal?.aborted
-            || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
+        } else {
+          try {
+            const inspected = await inspectServable(childSessionId)
+            header = inspected.meta
+            events = inspected.events
+            projections = beforeSeq === undefined
+              ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
+              : undefined
+          } catch (error: unknown) {
+            if (signal?.aborted) {
+              return err(request, {
+                code: 'cancelled',
+                message: 'subagent history read was cancelled',
+                details: {},
+              })
+            }
+            if (error instanceof SessionNotFound) {
+              return err(request, {
+                code: 'subagent-not-found',
+                message: 'subagent disappeared during history read',
+                details: { parentSessionId, childSessionId },
+              })
+            }
             return err(request, {
-              code: 'cancelled',
-              message: 'subagent history read was cancelled',
+              code: 'internal',
+              message: 'subagent history read failed',
               details: {},
             })
           }
-          if (error instanceof SessionQueryError
-            && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
-            return err(request, {
-              code: 'subagent-not-found',
-              message: 'subagent disappeared during history read',
-              details: { parentSessionId, childSessionId },
-            })
-          }
+        }
+        if (signal?.aborted) {
           return err(request, {
-            code: 'internal',
-            message: 'subagent history read failed',
+            code: 'cancelled',
+            message: 'subagent history read was cancelled',
             details: {},
           })
         }
+        if (header.parentSession !== parentSessionId) {
+          return err(request, {
+            code: 'subagent-unauthorized',
+            message: 'subagent parent changed during history read',
+            details: { childSessionId },
+          })
+        }
+        const page = historyPage(ctx, events, beforeSeq, maxMessages)
+        return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
       },
 
       async prompt(request, signal) {

+ 2 - 1
packages/host/apiproxy/src/api/subagents.ts

@@ -70,7 +70,8 @@ export interface SubagentsApi {
   ): Promise<RpcResponse<SubagentCatalog>>
 
   /**
-   * Reads one healthy catalog child's persisted raw log with ordinary
+   * Reads one healthy catalog child's transcript — the in-memory snapshot of
+   * a live child, the persisted log of a cold one — with ordinary
    * message-aligned pagination and render intents, without Agent activation.
    */
   history(

+ 42 - 0
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -190,6 +190,7 @@ describe('subagent ownership fence', () => {
     const meta = header('session-child', 1000, {
       parentSession: sid('session-parent'),
       seedLength: 0,
+      origin: 'subagent',
     })
     const events = [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
@@ -245,6 +246,47 @@ describe('subagent ownership fence', () => {
     expect(inspect).toHaveBeenCalledTimes(3)
   })
 
+  it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const sessionId = sid('session-legacy-child')
+    const meta = header('session-legacy-child', 1000, {
+      parentSession: sid('session-parent'),
+      seedLength: 0,
+    })
+    const events = [
+      {
+        type: 'subagent/descriptor',
+        seq: 0,
+        time: 1,
+        data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
+      },
+    ] as SessionEvent[]
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events }),
+      locate: () => undefined,
+    } as never)
+    // Pre-#1569 stores classify a child only through the descriptor event and
+    // carry no header `origin`; the pre-release decision stops recognizing
+    // them, so the ownership fence lets generic resume reach the registry
+    // instead of answering `agent-busy`.
+    const resume = vi.spyOn(ctx.agents, 'resume')
+      .mockRejectedValue(new Error('registry unavailable in this bench'))
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const prompt = await api.sessions.prompt(request({
+      sessionId,
+      mode: 'queue',
+      content: [{ type: 'text', text: 'follow up' }],
+    }))
+    expect(resume).toHaveBeenCalledTimes(1)
+    expect(prompt.result.ok).toBe(false)
+    if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal')
+  })
+
   it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

+ 109 - 23
packages/host/apiproxy/tests/api-proxy-subagents.spec.ts

@@ -1,7 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import { RpcId } from '../src/api/rpc.ts'
 import type { RpcRequest } from '../src/api/rpc.ts'
@@ -21,7 +20,12 @@ function bench(options: {
   entries?: object[]
   followupError?: Error
   listError?: Error
-  readError?: Error
+  /** Persistence forgets the child entirely (the vanished-mid-read race). */
+  storedChild?: false
+  /** Attach the child to the live session store instead of persistence only. */
+  liveChild?: true
+  /** Every registered projection unit throws on this child's payloads. */
+  projectionsThrow?: true
   historyParent?: SessionId
 } = {}) {
   const parent = { id: PARENT }
@@ -49,25 +53,44 @@ function bench(options: {
   ) => options.followupError === undefined
     ? Promise.resolve('message-1')
     : Promise.reject(options.followupError))
-  const readSession = vi.fn(() => options.readError === undefined
-    ? Promise.resolve({
-      session: {
-        version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
-      } satisfies SessionHeader,
-      events: [
-        { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
-      ] as unknown as SessionEvent[],
-    })
-    : Promise.reject(options.readError))
+  const childHeader = {
+    version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
+  } satisfies SessionHeader
+  const childEvents = [
+    { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
+  ] as unknown as SessionEvent[]
+  const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
+  const liveBlock = { values: {}, asOfSeq: 3 }
+  const coldBlock = { values: {}, asOfSeq: 0 }
+  const snapshot = vi.fn(() => {
+    if (options.projectionsThrow === true) throw new Error('hostile unit')
+    return liveBlock
+  })
+  const restore = vi.fn(() => {
+    if (options.projectionsThrow === true) throw new Error('hostile unit')
+    return { snapshot: coldBlock }
+  })
   const ctx = new Context()
   ctx.provide('agents', { get: getAgent })
   ctx.provide('subagents', { listChildren, followup })
-  ctx.provide('sessionQuery', { readSession })
+  ctx.provide('sessions', {
+    get: (id: SessionId) => options.liveChild === true && id === CHILD
+      ? { id: CHILD, header: childHeader, events: childEvents }
+      : undefined,
+  })
+  ctx.provide('sessionPersistence', {
+    list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]),
+    inspect,
+    locate: () => undefined,
+  })
+  // The gateway's own projection push feed subscribes at construction; the
+  // no-op disposer keeps that seam quiet while these tests pin history reads.
+  ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
   ctx.provide('userInteraction', { registerProvider: () => () => {} })
   const api = createApiProxy(ctx, {
     provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
   })
-  return { api, getAgent, listChildren, readSession, followup, parent }
+  return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
 }
 
 describe('subagent gateway', () => {
@@ -113,7 +136,7 @@ describe('subagent gateway', () => {
   })
 
   it('reads a healthy direct child without looking up or activating any Agent', async () => {
-    const { api, getAgent, readSession } = bench()
+    const { api, getAgent, inspect, restore } = bench()
     const response = await api.subagents.history(request({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
     }))
@@ -121,27 +144,65 @@ describe('subagent gateway', () => {
       ok: true,
       value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
     })
-    expect(readSession).toHaveBeenCalledWith(CHILD)
+    expect(inspect).toHaveBeenCalledWith(CHILD)
+    expect(restore).toHaveBeenCalledTimes(1)
     expect(getAgent).not.toHaveBeenCalled()
   })
 
+  it('serves a live child from the in-memory snapshot and the watermark projections', async () => {
+    const { api, inspect, snapshot, restore } = bench({ liveChild: true })
+    const response = await api.subagents.history(request({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
+    }))
+    expect(response.result).toMatchObject({
+      ok: true,
+      value: { hasMore: false, projections: { asOfSeq: 3 } },
+    })
+    expect(snapshot).toHaveBeenCalledTimes(1)
+    expect(restore).not.toHaveBeenCalled()
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('serves the page without projections when a hostile unit breaks the fold', async () => {
+    const cold = bench({ projectionsThrow: true })
+    const coldResponse = await cold.api.subagents.history(request({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
+    }))
+    expect(coldResponse.result).toMatchObject({
+      ok: true,
+      value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
+    })
+    if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
+
+    const live = bench({ projectionsThrow: true, liveChild: true })
+    const liveResponse = await live.api.subagents.history(request({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
+    }))
+    expect(liveResponse.result).toMatchObject({
+      ok: true,
+      value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
+    })
+    if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
+    expect(live.snapshot).toHaveBeenCalledTimes(1)
+  })
+
   it('reads one-shot history and rejects an address with the wrong mode', async () => {
     const oneShot = {
       kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
       activity: 'inactive', hasChildren: false,
     }
-    const { api, readSession } = bench({ entries: [oneShot] })
+    const { api, inspect } = bench({ entries: [oneShot] })
     expect((await api.subagents.history(request({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
     }))).result).toMatchObject({ ok: true })
     expect((await api.subagents.history(request({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
     }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
-    expect(readSession).toHaveBeenCalledTimes(1)
+    expect(inspect).toHaveBeenCalledTimes(1)
   })
 
   it('rejects a diagnostic address before reading history', async () => {
-    const { api, readSession } = bench({ entries: [
+    const { api, inspect } = bench({ entries: [
       { kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
     ] })
     const response = await api.subagents.history(request({
@@ -154,7 +215,34 @@ describe('subagent gateway', () => {
         details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
       },
     })
-    expect(readSession).not.toHaveBeenCalled()
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
+    const listError = () => new SubagentError(
+      'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
+      'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
+    )
+    const expected = {
+      code: 'internal',
+      message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
+    }
+
+    const list = bench({ listError: listError() })
+    expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
+      .toMatchObject({ ok: false, error: expected })
+
+    const history = bench({ listError: listError() })
+    expect((await history.api.subagents.history(request({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
+    }))).result).toMatchObject({ ok: false, error: expected })
+    expect(history.inspect).not.toHaveBeenCalled()
+
+    const prompt = bench({ listError: listError() })
+    expect((await prompt.api.subagents.prompt(request({
+      parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
+    }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
+    expect(prompt.followup).not.toHaveBeenCalled()
   })
 
   it('routes human content through the exact live parent with rpc attribution', async () => {
@@ -193,9 +281,7 @@ describe('subagent gateway', () => {
   })
 
   it('maps history disappearance and hides unexpected backend details', async () => {
-    const disappeared = bench({
-      readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
-    })
+    const disappeared = bench({ storedChild: false })
     expect((await disappeared.api.subagents.history(request({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
     }))).result).toMatchObject({

+ 19 - 1
packages/session-query/session-query/tests/session-query.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context, type Fiber } from 'cordis'
 import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
-import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
+import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
 import SessionQueryService, {
   SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
   type SessionEventSurface,
@@ -1114,6 +1114,24 @@ describe('session-query exact reads', () => {
     await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
   })
 
+  it('wraps persisted corruption as SESSION_QUERY_CORRUPT_SESSION with its cause preserved', async () => {
+    const durable = header('durable-corrupt')
+    TestPersistence.reset([{ meta: durable, events: eventLog() }])
+    const ctx = await liveContext()
+    await ctx.plugin(TestPersistence)
+    const corruption = new SessionPersistenceCorruptionError(
+      'stored prefix failed validation',
+      { cause: new Error('torn final record') },
+    )
+    TestPersistence.inspectFailure = corruption
+
+    await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({
+      code: 'SESSION_QUERY_CORRUPT_SESSION',
+      message: `stored session "${durable.id}" is corrupt: stored prefix failed validation`,
+      cause: corruption,
+    })
+  })
+
   it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => {
     const durable = header('durable')
     TestPersistence.reset([{ meta: durable, events: eventLog() }])

+ 2 - 2
packages/subagent/subagent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
-README.md: 08b6175e018db072b99490a25b8df887bb89eb47
-README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3
+README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
+README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
packages/subagent/subagent/README.md


Разница между файлами не показана из-за своего большого размера
+ 2 - 2
packages/subagent/subagent/README.zh.md


+ 6 - 4
packages/subagent/subagent/package.json

@@ -40,8 +40,8 @@
     "@deepseek-ai/dsh-scope": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence": "^0.0.1",
-    "@deepseek-ai/dsh-session-query": "^0.0.1",
     "@deepseek-ai/dsh-session-projection": "^0.0.1",
+    "@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
     "@deepseek-ai/dsh-tasks": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
@@ -50,10 +50,10 @@
     "@deepseek-ai/dsh-session-persistence": {
       "optional": true
     },
-    "@deepseek-ai/dsh-session-query": {
+    "@deepseek-ai/dsh-session-projection": {
       "optional": true
     },
-    "@deepseek-ai/dsh-session-projection": {
+    "@deepseek-ai/dsh-session-projection-cache": {
       "optional": true
     },
     "@deepseek-ai/dsh-tasks": {
@@ -68,8 +68,10 @@
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
-    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-storage": "workspace:^",
+    "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-tasks": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "cordis": "^4.0.0-rc.7"

+ 1 - 1
packages/subagent/subagent/src/client.ts

@@ -4,4 +4,4 @@
  * @module @deepseek-ai/dsh-subagent/client
  */
 
-export type { SubagentTimingProjection } from './projection-types.ts'
+export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'

+ 29 - 18
packages/subagent/subagent/src/index.ts

@@ -20,8 +20,8 @@
  * continuation manager holds their `AgentHandle` directly and orders every turn
  * through the child's own inbox, so providers contribute only the detached
  * creation spec and see no handle, turn, or teardown. Direct-child discovery
- * independently interprets the optional session-query corpus and does not
- * require that continuation runtime.
+ * reads the live session store and optional session persistence directly and
+ * does not require that continuation runtime.
  *
  * Same-process providers are trusted typed collaborators. Requests, provider
  * descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -65,7 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
 import { listChildren as listSubagentChildren } from './list-children.ts'
 import type { SubagentListEntry } from './list-children.ts'
 import { snapshotSubagentDescriptor } from './descriptor.ts'
-import { subagentTimingProjectionDefinition } from './projection.ts'
+import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
 
 export * from './out-of-process.ts'
 export { SubagentRunId } from './types.ts'
@@ -118,7 +118,7 @@ export type {
 export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
 export type { SubagentListEntry } from './list-children.ts'
 export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
-export type { SubagentTimingProjection } from './projection-types.ts'
+export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
 
 declare module 'cordis' {
   interface Context {
@@ -190,6 +190,7 @@ export class SubagentService extends Service {
     })
     ctx.inject(['sessionProjections'], (projectionCtx) => {
       projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
+      projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition)
     })
   }
 
@@ -283,22 +284,32 @@ export class SubagentService extends Service {
   }
 
   /**
-   * Enumerate the parent's direct session-backed subagents from the
-   * live-preferred session corpus without loading or resuming an Agent. Session
-   * query supplies lineage, candidate order, event reads, and live state; this
-   * service interprets descriptor mode, activity, and per-child diagnostics
-   * without consulting Agent registrations, Activations, or providers.
+   * Enumerate the parent's direct session-backed subagents without loading or
+   * resuming an Agent and without any query seam: 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.
    *
-   * The trace and exact descriptor read receive `signal`; the full event-list
-   * read has no signal parameter, so the scan rechecks cancellation around
-   * every await and between candidates. Query rejections that settle after an
-   * abort become a stable `SubagentError` with code `CANCELLED`.
+   * Every persistence read receives `signal`, and the listing rechecks
+   * cancellation around each of those awaits. 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 where supported and
-   *   observed around every query await.
-   * @returns children and per-child diagnostics in stable trace order.
-   * @throws {@link SubagentError} when session query is unavailable or the
-   *   caller cancels the scan.
+   * @param signal - caller-owned cancellation forwarded to persistence reads
+   *   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
+   *   store is not mounted, or the caller cancels the listing.
    */
   listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
     return listSubagentChildren(this.ctx, parentSessionId, signal)

+ 254 - 156
packages/subagent/subagent/src/list-children.ts

@@ -1,33 +1,47 @@
 /**
- * Read-only interpretation of session-query lineage as durable subagent
- * children. Only descendants with durable `origin: 'subagent'` enter per-child
- * inspection. The module owns no catalog state and does not consult Activation,
- * Agent-registry, continuation-manager, or provider state. A child's descriptor
- * distinguishes one-shot work from a continuable conversation.
+ * Read-only enumeration of one parent's durable subagent children straight
+ * from the live session store and optional session persistence — no query
+ * seam. Candidates are the live-preferred merge of both listings filtered to
+ * durable `origin: 'subagent'` under the parent; each child's mode/label is
+ * the registered `subagent` projection unit's value, resolved down a
+ * three-rung ladder: the registry's watermark cache for a live child, a
+ * durable projection-cache row when it serves an own-suffix identity (the
+ * seq gate), and one persistence inspection folded through the registry
+ * otherwise, validated against the enumerated lifecycle. The projection
+ * fold is the single
+ * classification authority — this module parses no descriptor itself. Absent
+ * persistence, enumeration is live-only: a cold child is unreachable for
+ * resume anyway, so its absence is capability absence, not an error. The
+ * module owns no catalog state and does not consult Activation,
+ * Agent-registry, continuation-manager, or provider state.
  *
  * @module @deepseek-ai/dsh-subagent
  */
 
 import type { Context } from 'cordis'
-import type { SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
-import type SubagentService from './index.ts'
+import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
+import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
 import { SubagentError } from './error.ts'
-import { foldSubagentDescriptor } from './descriptor.ts'
+import type { SubagentIdentityProjection } from './projection-types.ts'
 
-type SessionQueryRuntime = Pick<
-  typeof import('@deepseek-ai/dsh-session-query'),
-  'assertSessionHeadersCompatible' | 'SessionQueryError'
->
+/**
+ * Concurrent cold inspections per listing; a constant because it bounds one
+ * read-only scan of local media, not deployment behavior. Should a networked
+ * persistence backend appear, promote it to a validated `Config` field.
+ */
+const COLD_READ_CONCURRENCY = 4
 
 /**
- * One entry of a {@link listChildren} result in trace candidate order. Only a
- * candidate whose durable header has `origin: 'subagent'` is inspected. A
- * valid descriptor produces a `child`, a per-child inspection failure produces
- * a `diagnostic`, and a candidate without its own descriptor is omitted.
- * Healthy rows include a one-level, origin-classified descendant hint.
- * Diagnostics are transient query results, never session events or catalog
- * state, and never expose model-hidden descriptor content.
+ * One entry of a {@link listChildren} result, ordered by header `createdAt`
+ * with ties broken on id. Only a candidate whose durable header has
+ * `origin: 'subagent'` is interpreted. A served `subagent` projection value
+ * produces a `child`; a settled candidate whose fold served no identity
+ * produces a `diagnostic`; a running candidate without one is omitted — its
+ * descriptor may not be appended yet (the creation window). Diagnostics
+ * relay the projection fold's outcome or a failed read, never a per-child
+ * event scan, and never expose model-hidden descriptor content.
  */
 export type SubagentListEntry =
   | {
@@ -35,7 +49,7 @@ export type SubagentListEntry =
     /** The durable child session id, stable across Activations. */
     readonly id: SessionId
     /**
-     * Corpus snapshot activity: `running` means the logical record is live in
+     * Store snapshot activity: `running` means the logical record is live in
      * `ctx.sessions`; `inactive` means it exists only in persistence. Neither
      * encodes a durable outcome, and a continuable child may still reject
      * delivery as an ownership conflict.
@@ -59,179 +73,263 @@ export type SubagentListEntry =
   )
   | {
     readonly kind: 'diagnostic'
-    /** The traced candidate's session id. */
+    /** The candidate's session id. */
     readonly id: SessionId
     /**
-     * Why the candidate was omitted: `corrupt` for invalid surfaces, header
-     * conflicts, or malformed/duplicated descriptors; `unsupported` for an
-     * unknown descriptor version; `unavailable` when the child disappeared or
-     * its per-child read hit a persistence failure.
+     * Why the candidate has no `child` row: `corrupt` for a settled candidate
+     * whose projection fold served no identity (a missing, malformed, or
+     * unrecognized-version descriptor — deliberately undistinguished), and
+     * for any candidate whose log makes a registered unit's fold or schema
+     * throw (deterministic data damage, contained per child); `unavailable`
+     * when the candidate's persistence inspection failed (retried on the
+     * next listing). `unsupported` is kept for consumers already routing on
+     * it but is no longer produced.
      */
     readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
   }
 
 /**
- * Interpret one parent's origin-classified direct descendants as session-backed
- * subagents without loading or resuming an Agent. Ordinary forks are skipped
- * before per-child event inspection.
- * @see {@link SubagentService.listChildren} for the public cancellation and
- *   failure contract.
- * @param ctx - context carrying the optional session-query service.
+ * Enumerate one parent's origin-classified direct children from the
+ * live-preferred merge of `ctx.sessions` and optional session persistence,
+ * serving each identity from the `subagent` projection unit: the registry's
+ * watermark snapshot for a live child; for a cold one, a durable
+ * projection-cache row when it serves an own-suffix identity (the seq gate),
+ * else one bounded-concurrency persistence inspection folded through the
+ * registry.
+ * @see SubagentService.listChildren for the public cancellation and failure contract.
+ * @param ctx - context carrying the session store, the projection registry,
+ *   optional persistence, and the optional projection cache.
  * @param parentSessionId - parent session whose direct children are listed.
- * @param signal - caller-owned cancellation.
- * @returns children and per-child diagnostics in stable trace order.
- * @throws {@link SubagentError} when session query is unavailable or
- *   the caller cancels the scan.
+ * @param signal - caller-owned cancellation observed around every persistence read.
+ * @returns children and per-child diagnostics ordered by `createdAt`, then id.
+ * @throws {@link SubagentError} when the projection registry or the session
+ *   store is not mounted, or the caller cancels the listing.
  */
 export async function listChildren(
   ctx: Context,
   parentSessionId: SessionId,
   signal?: AbortSignal,
-): ReturnType<SubagentService['listChildren']> {
-  const query = ctx.get('sessionQuery')
-  if (query === undefined) {
+): Promise<SubagentListEntry[]> {
+  const projections = ctx.get('sessionProjections')
+  // Checked before any read, even with zero candidates: mode/label are the
+  // row's strong contract, so a missing fold capability is a deterministic
+  // deployment configuration error, never an empty success.
+  if (projections === undefined) {
     throw new SubagentError(
-      'listing subagents requires session query (load a dsh-session-query backend)',
-      'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
+      'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
+      'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
     )
   }
-  assertListingNotCancelled(signal)
-  // Keep runtime values behind the listing-only boundary so ordinary
-  // subagent imports and control operations do not evaluate the optional peer.
-  const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
-  assertListingNotCancelled(signal)
-  const trace = await runListingQuery(
-    () => query.traceSession(parentSessionId, signal),
-    signal,
-  )
-  const entries: SubagentListEntry[] = []
-  for (const node of trace.descendants) {
-    if (node.session.header.origin !== 'subagent') continue
-    const hasChildren = node.descendants.some(
-      descendant => descendant.session.header.origin === 'subagent',
-    )
-    const entry = await inspectChild(
-      query, queryRuntime, parentSessionId, node.session, hasChildren, signal,
+  // Strict global read, never the `ctx.sessions` property proxy: the proxy is
+  // caller-scope bound, so a consumer plugin without its own `sessions`
+  // injection (the model-facing tool, the API proxy) would throw on access.
+  const sessions = ctx.get('sessions')
+  if (sessions === undefined) {
+    throw new SubagentError(
+      'listing subagents requires the session store (load @deepseek-ai/dsh-session)',
+      'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE',
     )
-    // Cancellation can race the inspection's last checkpoint or diagnostic
-    // mapping; do not return success or begin another candidate afterward.
+  }
+  assertListingNotCancelled(signal)
+  const persistence = ctx.get('sessionPersistence')
+  // Optional acceleration only: an absent cache service just means every
+  // cold candidate takes the authoritative preparation rung, so it carries
+  // no error code and no configuration check.
+  const cache = ctx.get('sessionProjectionCache')
+  let persistedHeaders: readonly SessionHeader[] = []
+  if (persistence !== undefined) {
+    try {
+      persistedHeaders = await persistence.list(signal)
+    } catch (error: unknown) {
+      // The backend may reject with its own abort failure after observing the
+      // forwarded signal; cancellation stays a stable subagent failure.
+      assertListingNotCancelled(signal)
+      throw error
+    }
     assertListingNotCancelled(signal)
-    if (entry !== undefined) entries.push(entry)
   }
-  return entries
-}
-
-/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
-async function inspectChild(
-  query: SessionQueryService,
-  queryRuntime: SessionQueryRuntime,
-  parentSessionId: SessionId,
-  candidate: SessionRecord,
-  hasChildren: boolean,
-  signal?: AbortSignal,
-): Promise<SubagentListEntry | undefined> {
-  const childId = candidate.header.id
-  try {
-    const records = await runListingQuery(() => query.listEvents(childId), signal)
-    // Only the child's own suffix: a fork seed may replay an ancestor's
-    // descriptor without making the fork itself a subagent.
-    const seedLength = candidate.header.seedLength ?? 0
-    const descriptorSeqs = records
-      .filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
-      .map(record => record.seq)
-    if (descriptorSeqs.length === 0) return undefined
-    if (descriptorSeqs.length > 1) {
-      return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+  // Live-preferred merge without header reconciliation: a live record wins
+  // its id wholesale, exactly as a live-preferred corpus would serve it.
+  const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>()
+  for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
+  for (const session of sessions.list()) {
+    corpus.set(session.header.id, { header: session.header, live: session })
+  }
+  const subagentParents = new Set<SessionId>()
+  for (const record of corpus.values()) {
+    if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) {
+      subagentParents.add(record.header.parentSession)
     }
-    // The length-one branch proves this exact-read sequence exists.
-    // oxlint-disable-next-line typescript/no-non-null-assertion
-    const seq = descriptorSeqs[0]!
-    const window = await runListingQuery(
-      () => query.readEvent({ sessionId: childId, seq }, signal),
-      signal,
-    )
-    queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
-    if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
-      return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+  }
+  const candidates = [...corpus.values()]
+    .filter(record => record.header.parentSession === parentSessionId
+      && record.header.origin === 'subagent')
+    .sort((a, b) => a.header.createdAt - b.header.createdAt
+      || a.header.id.localeCompare(b.header.id))
+
+  const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
+  const coldReads: { index: number; header: SessionHeader }[] = []
+  candidates.forEach((candidate, index) => {
+    const childId = candidate.header.id
+    if (candidate.live === undefined) {
+      coldReads.push({ index, header: candidate.header })
+      return
     }
-    let descriptor: ReturnType<typeof foldSubagentDescriptor>
+    // The registry's watermark cache serves the live value with zero log
+    // reads; a live child without an identity yet is the creation window
+    // before the establishing provider appends its descriptor.
+    let identity: SubagentIdentityProjection | null | undefined
     try {
-      descriptor = foldSubagentDescriptor([window.target])
+      identity = projections.snapshot(candidate.live).values.subagent
     } catch {
-      return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+      // The snapshot folds EVERY registered unit over this child's log, so
+      // any unit's fold or schema can reject damaged payloads. That is
+      // deterministic data damage in this one child; it degrades to one
+      // corrupt diagnostic instead of failing the whole listing.
+      rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+      return
     }
-    if (descriptor === undefined) {
-      return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
-    }
-    const activity = candidate.live ? 'running' : 'inactive'
-    if (descriptor.mode === 'one-shot') {
-      return {
-        kind: 'child',
-        id: childId,
-        mode: descriptor.mode,
-        ...descriptor.label !== undefined ? { label: descriptor.label } : {},
-        activity,
-        hasChildren,
-      }
-    }
-    return {
-      kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label,
-      activity, hasChildren,
-    }
-  } catch (error: unknown) {
-    const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
-    if (reason === undefined) throw error
-    return { kind: 'diagnostic', id: childId, reason }
-  }
-}
+    // The unit's serializable no-value sentinel is `null`; `undefined` can
+    // only mean the key was dropped at a JSON boundary. Both are no value.
+    if (identity === undefined || identity === null) return
+    rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
+  })
 
-/** Stop a listing scan at its next cancellation checkpoint. */
-function assertListingNotCancelled(signal: AbortSignal | undefined): void {
-  if (signal?.aborted) {
-    throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
+  // Cold candidates exist only when persistence listed them, so the narrow
+  // re-check is about types, not reachability.
+  if (persistence !== undefined && coldReads.length > 0) {
+    const queue = [...coldReads]
+    await Promise.all(Array.from(
+      { length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
+      async () => {
+        for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
+          rows[job.index] = await resolveColdIdentity(
+            persistence, projections, cache, job.header,
+            subagentParents.has(job.header.id), signal,
+          )
+        }
+      },
+    ))
   }
+  assertListingNotCancelled(signal)
+  return rows.filter((row): row is SubagentListEntry => row !== undefined)
 }
 
 /**
- * Run one session-query operation between cancellation checkpoints. Query
- * implementations may reject with their own abort error after observing the
- * forwarded signal; cancellation remains a stable subagent failure.
+ * Resolve one cold candidate down the remaining ladder: a durable
+ * projection-cache row when it serves an own-suffix identity (the seq gate),
+ * otherwise one persistence inspection folded through the projection
+ * registry (the same detached recipe the API proxy uses for detached session
+ * projections). A failed inspection is one transient `unavailable` row
+ * retried on the next listing; an inspection naming another lifecycle, and a
+ * settled log the fold cannot identify — or that makes any registered unit
+ * throw — are final, so they report `corrupt`.
  */
-async function runListingQuery<T>(
-  operation: () => Promise<T>,
+async function resolveColdIdentity(
+  persistence: SessionPersistence,
+  projections: SessionProjectionRegistry,
+  cache: SessionProjectionCache | undefined,
+  header: SessionHeader,
+  hasChildren: boolean,
   signal: AbortSignal | undefined,
-): Promise<T> {
+): Promise<SubagentListEntry> {
+  const childId = header.id
+  if (cache !== undefined) {
+    let cached: SubagentIdentityProjection | null | undefined
+    try {
+      cached = cache.cachedSnapshot(header)?.values.subagent
+    } catch {
+      // Unlike the preparation fold below, a throwing cache read renders no
+      // verdict: the cache is derived data, so its damage (a poisoned stored
+      // row of ANY unit) silently falls through to the authoritative re-fold.
+      cached = undefined
+    }
+    // A child's OWN descriptor is immutable once appended, so a cached
+    // identity is final only when the seq gate proves it was folded from the
+    // own suffix: a creation-window checkpoint may instead carry a fork
+    // seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
+    // must not outrank the re-fold. Everything else also falls through to
+    // preparation: an absent key (a cut before any descriptor) and the
+    // `null` sentinel, whose verdict belongs to the authoritative re-fold,
+    // not to a derived row.
+    if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
+      return childRow(childId, cached, 'inactive', hasChildren)
+    }
+  }
   assertListingNotCancelled(signal)
+  let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
   try {
-    const result = await operation()
-    assertListingNotCancelled(signal)
-    return result
-  } catch (error: unknown) {
+    inspected = await persistence.inspect(childId, signal)
+  } catch {
+    // Per-child isolation: the child vanished or its backend read failed —
+    // one diagnostic row, and the listing itself still succeeds.
     assertListingNotCancelled(signal)
-    throw error
+    return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
+  }
+  assertListingNotCancelled(signal)
+  // A session id names a slot, not a lifecycle: a child deleted and
+  // re-published under another owner between the enumeration and this read
+  // must not leak into the old parent's listing.
+  if (!sameLifecycle(inspected.meta, header)) {
+    return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+  }
+  let identity: SubagentIdentityProjection | null | undefined
+  try {
+    identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
+  } catch {
+    // The restore folds EVERY registered unit over this child's log, so any
+    // unit's fold or schema can reject damaged payloads — deterministic data
+    // damage in this one child, contained as its own corrupt diagnostic.
+    return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+  }
+  if (identity === undefined || identity === null) {
+    return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
   }
+  return childRow(childId, identity, 'inactive', hasChildren)
+}
+
+/** Materialize one served identity as its child row. */
+function childRow(
+  id: SessionId,
+  identity: SubagentIdentityProjection,
+  activity: 'running' | 'inactive',
+  hasChildren: boolean,
+): SubagentListEntry {
+  return identity.mode === 'one-shot'
+    ? {
+      kind: 'child',
+      id,
+      mode: 'one-shot',
+      ...identity.label !== undefined ? { label: identity.label } : {},
+      activity,
+      hasChildren,
+    }
+    : {
+      kind: 'child',
+      id,
+      mode: 'continuable',
+      label: identity.label,
+      activity,
+      hasChildren,
+    }
 }
 
+/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
+const LIFECYCLE_WITNESS_KEYS = [
+  'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
+] as const
+
 /**
- * Map a per-child query failure to a fixed diagnostic. Configuration errors
- * and unrecognized failures remain operation failures.
+ * Whether an inspected log still belongs to the enumerated lifecycle,
+ * mirroring the retired query-source compatibility check's field set.
  */
-function perChildDiagnosticReason(
-  error: unknown,
-  SessionQueryError: SessionQueryRuntime['SessionQueryError'],
-): 'corrupt' | 'unavailable' | undefined {
-  if (!(error instanceof SessionQueryError)) return undefined
-  switch (error.code) {
-    case 'SESSION_QUERY_CORRUPT_SESSION':
-      return 'corrupt'
-    case 'SESSION_QUERY_SESSION_NOT_FOUND':
-    case 'SESSION_QUERY_EVENT_NOT_FOUND':
-    case 'SESSION_QUERY_PERSISTENCE_FAILED':
-      return 'unavailable'
-    case 'SESSION_QUERY_INVALID_SURFACE':
-    case 'SESSION_QUERY_SOURCE_CONFLICT':
-      return 'corrupt'
-    default:
-      return undefined
+function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
+  return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
+}
+
+/** Stop a listing at its next cancellation checkpoint. */
+function assertListingNotCancelled(signal: AbortSignal | undefined): void {
+  if (signal?.aborted) {
+    throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
   }
 }

+ 39 - 0
packages/subagent/subagent/src/projection-types.ts

@@ -17,9 +17,48 @@ export interface SubagentTimingProjection {
   }
 }
 
+/**
+ * Durable identity of one descriptor-backed subagent session: lifecycle mode
+ * plus creation label, folded last-wins from `subagent/descriptor` events.
+ * Label strength follows the descriptor schema: a continuable child always
+ * carries one, a one-shot child may omit it.
+ */
+export type SubagentIdentityProjection =
+  | {
+    /** A terminal one-shot child. */
+    mode: 'one-shot'
+    /** Optional durable creation label from the child's descriptor. */
+    label?: string
+    /**
+     * Seq of the `subagent/descriptor` event this identity was folded from.
+     * `seq >= header.seedLength` proves the identity comes from the child's
+     * OWN log suffix — where a descriptor is immutable once appended — and
+     * not from a fork seed's replayed ancestor descriptor.
+     */
+    seq: number
+  }
+  | {
+    /** A resumable conversation. */
+    mode: 'continuable'
+    /** Durable creation label from the child's descriptor. */
+    label: string
+    /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
+    seq: number
+  }
+
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionMap {
     /** Active-turn duration for a descriptor-backed subagent session. */
     subagentTiming: SubagentTimingProjection
+    /**
+     * Identity of a descriptor-backed subagent session. `null` ⟺ no valid
+     * descriptor (missing, malformed, or unrecognized-version — deliberately
+     * undistinguished). The sentinel is deliberately serializable: a
+     * value pushed over JSON transports must survive `JSON.stringify`
+     * losslessly, where an `undefined` field would be dropped and a stale
+     * identity would survive on the receiving side. The entry itself stays
+     * non-optional.
+     */
+    subagent: SubagentIdentityProjection | null
   }
 }

+ 76 - 2
packages/subagent/subagent/src/projection.ts

@@ -1,12 +1,16 @@
 /**
- * Pure session projection for subagent active-turn duration.
+ * Pure session projections for subagent identity (mode/label) and active-turn
+ * duration.
  *
  * @module @deepseek-ai/dsh-subagent/projection
  */
 
 import { z } from 'zod'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import type { SubagentTimingProjection } from './projection-types.ts'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { foldSubagentDescriptor } from './descriptor.ts'
+import type { SubagentDescriptorData } from './descriptor.ts'
+import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
 
 interface TimingState {
   /** Milliseconds accumulated across completed post-descriptor turns. */
@@ -80,3 +84,73 @@ ProjectionDefinition<'subagentTiming', TimingState> = {
   }),
   stateVersion: 2,
 }
+
+interface IdentityState {
+  /** Identity from the last valid descriptor; absent before one, and after an invalid one. */
+  identity?: SubagentIdentityProjection
+}
+
+// The cast bridges only the optional-label arm: Zod's optional output
+// includes explicit `undefined`, which exactOptionalPropertyTypes excludes
+// from the public interface. The no-value state itself is the serializable
+// `null` arm — never `undefined` — so every registry read and push frame
+// survives JSON.stringify losslessly.
+const identitySchema = z.discriminatedUnion('mode', [
+  z.object({
+    mode: z.literal('one-shot'),
+    label: z.string().optional(),
+    seq: z.number().int().nonnegative(),
+  }).strict(),
+  z.object({
+    mode: z.literal('continuable'),
+    label: z.string(),
+    seq: z.number().int().nonnegative(),
+  }).strict(),
+]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
+
+/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
+function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
+  let descriptor: SubagentDescriptorData | undefined
+  try {
+    descriptor = foldSubagentDescriptor([event])
+  } catch {
+    // Only a malformed current-version payload throws in descriptor parsing;
+    // a projection fold must never throw, so damage folds to no value.
+    descriptor = undefined
+  }
+  if (descriptor === undefined) return undefined
+  return descriptor.mode === 'one-shot'
+    ? {
+      mode: 'one-shot',
+      ...descriptor.label !== undefined ? { label: descriptor.label } : {},
+      seq: event.seq,
+    }
+    : { mode: 'continuable', label: descriptor.label, seq: event.seq }
+}
+
+/**
+ * Fold the durable mode/label identity from `subagent/descriptor` events,
+ * last-wins: a fork seed may replay an ancestor's descriptor, and the child's
+ * own descriptor must override it — the same reset discipline as
+ * {@link subagentTimingProjectionDefinition}. A malformed or unknown-version
+ * payload resets to the `null` sentinel instead of throwing, so a fork of a
+ * healthy ancestor never inherits an identity its own descriptor failed to
+ * establish — and the reset survives every JSON push frame, so a consumer
+ * holding the earlier identity replaces it instead of keeping it stale;
+ * `null` ⟺ no valid descriptor, with the causes deliberately undistinguished.
+ */
+export const subagentIdentityProjectionDefinition:
+ProjectionDefinition<'subagent', IdentityState> = {
+  key: 'subagent',
+  schema: identitySchema,
+  init: () => ({}),
+  apply: (state, event) => {
+    if (event.type !== 'subagent/descriptor') return state
+    const identity = descriptorIdentity(event)
+    return identity === undefined ? {} : { identity }
+  },
+  view: state => state.identity ?? null,
+  // Bumped when the identity gained its `seq` field: an older checkpoint row
+  // would replay into a value the schema rejects, so it must refold instead.
+  stateVersion: 2,
+}

+ 495 - 200
packages/subagent/subagent/tests/list-children.spec.ts

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { z } from 'zod'
 import { Context } from 'cordis'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -9,7 +10,12 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
 import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
-import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
+import Storage from '@deepseek-ai/dsh-storage'
+import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
+import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
 import SubagentService, {
   SUBAGENT_DESCRIPTOR_VERSION,
   SubagentError,
@@ -17,7 +23,6 @@ import SubagentService, {
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
 import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
-import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
@@ -26,18 +31,29 @@ afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
-/** Boot the continuable stack plus a concrete session-query service. */
-async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
+/** Boot the continuable stack with real JSONL session persistence. */
+async function setup(
+  script: Script,
+  options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
+) {
   const ctx = new Context()
   await mountAgentLoopTestDependencies(ctx)
   const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
   roots.push(root)
   await ctx.plugin(JsonlSessionPersistence, { root })
   await ctx.plugin(AgentLoop, { agents: [] })
+  if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
+  if (options.projectionCache === true) {
+    await ctx.plugin(Storage)
+    ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
+    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
+    ctx.storage.mount('domain', facility)
+    ctx.provide('storageDomain', facility)
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
+  }
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(SubagentFork, { providerName: 'fork' })
-  if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
   ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
   const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
   return { ctx, parent }
@@ -101,38 +117,76 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
   return { version, mode: 'continuable' as const, provider: 'spawn', label }
 }
 
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
+    /** Test-only hostile probe proving per-child isolation of foreign unit failures. */
+    subagentListHostileProbe: null
+  }
+}
+
+/**
+ * A foreign registered unit that rejects one specific child's log at view
+ * time: `apply` never throws (the eager drive passes every committed event
+ * through it), while the poisoned state detonates only when a listing read
+ * folds or serves this child through the registry.
+ */
+const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = {
+  key: 'subagentListHostileProbe',
+  schema: z.null(),
+  init: () => ({}),
+  apply: (state, event) =>
+    event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me'
+      ? { poisoned: true }
+      : state,
+  view: (state) => {
+    if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
+    return null
+  },
+  stateVersion: 1,
+}
+
 describe('SubagentService.listChildren', () => {
-  it('lists through session query without the Activation continuation runtime', async () => {
+  it('lists live children without persistence, query services, or the continuation runtime', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SubagentService)
-    await ctx.plugin(TestSessionQueryService)
     expect(ctx.get('tasks')).toBeUndefined()
     expect(ctx.get('agents')).toBeUndefined()
+    expect(ctx.get('sessionPersistence')).toBeUndefined()
 
-    const parentId = SessionId('query-only-parent')
+    const parentId = SessionId('live-only-parent')
     ctx.sessions.create(parentId)
-    const childId = SessionId('query-only-child')
+    const childId = SessionId('live-only-child')
     const child = ctx.sessions.create(childId, {
       meta: { parentSession: parentId, origin: 'subagent' },
     })
     child.append('turn/start', {
       turn: 1,
     })
-    child.append('subagent/descriptor', descriptorPayload('query-only child'))
+    child.append('subagent/descriptor', descriptorPayload('live-only child'))
 
     await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
       {
-        kind: 'child', id: childId, label: 'query-only child', mode: 'continuable',
+        kind: 'child', id: childId, label: 'live-only child', mode: 'continuable',
         activity: 'running', hasChildren: false,
       },
     ])
   })
 
-  it('fails loud before any work when session query is not loaded', async () => {
-    const { ctx, parent } = await setup([], { sessionQuery: false })
+  it('fails loud when the projection registry is not mounted, even with no children', async () => {
+    const { ctx, parent } = await setup([], { sessionProjections: false })
     await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
-      expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
+      expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
+    )
+  })
+
+  it('fails loud when the session store is not mounted', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionProjectionRegistry)
+    await ctx.plugin(SubagentService)
+    await expect(ctx.subagents.listChildren(SessionId('no-store-parent'))).rejects.toThrow(
+      expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) as Error,
     )
   })
 
@@ -148,7 +202,7 @@ describe('SubagentService.listChildren', () => {
     ])
   })
 
-  it('lists one-shot and continuable children from the same trace', async () => {
+  it('lists one-shot and continuable children under the same parent', async () => {
     const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
     const oneShot = await ctx.subagents.start('spawn', {
       prompt: [{ type: 'text', text: 'finish once' }],
@@ -205,33 +259,59 @@ describe('SubagentService.listChildren', () => {
     ])
   })
 
-  it('orders children by createdAt then id without inspecting ordinary forks', async () => {
+  it('orders children by createdAt then id without listing ordinary forks', async () => {
     const { ctx, parent } = await setup([])
-    // Authored headers pin the ordering key deterministically: same createdAt
-    // ties break on id, different createdAt orders ascending.
-    const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
-      parentSession: parent.id,
-      createdAt: 9,
-      origin: 'subagent',
-    }, childEvents(descriptorPayload('late child')))
-    const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
-      parentSession: parent.id,
-      createdAt: 5,
-      origin: 'subagent',
-    }, childEvents(descriptorPayload('tie b')))
-    const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
-      parentSession: parent.id,
-      createdAt: 5,
-      origin: 'subagent',
-    }, childEvents(descriptorPayload('tie a')))
+    /** Publish one live child with a pinned header ordering key. */
+    const liveChild = (parentId: SessionId, id: string, createdAt: number, label: string): SessionId => {
+      const session = ctx.sessions.create(SessionId(id), {
+        meta: { parentSession: parentId, origin: 'subagent', createdAt },
+      })
+      session.append('turn/start', { turn: 1 })
+      session.append('subagent/descriptor', descriptorPayload(label))
+      return session.header.id
+    }
+    // Live creation order is deliberately shuffled against the expected
+    // result: same-createdAt ties break on id, different createdAt orders
+    // ascending.
+    const late = liveChild(parent.id, '00000000-0000-4000-8000-000000000009', 9, 'late child')
+    const tieB = liveChild(parent.id, '00000000-0000-4000-8000-000000000002', 5, 'tie b')
+    const tieA = liveChild(parent.id, '00000000-0000-4000-8000-000000000001', 5, 'tie a')
     // An ordinary session fork shares parentSession but has no subagent origin.
     const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
     await ctx.sessions.flush(fork)
-    const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents')
     const entries = await ctx.subagents.listChildren(parent.id)
     expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
     expect(entries.every(entry => entry.kind === 'child')).toBe(true)
-    expect(listEvents).not.toHaveBeenCalledWith(fork.id)
+  })
+
+  it('omits a live child that has not appended its descriptor yet', async () => {
+    const { ctx, parent } = await setup([])
+    const pending = ctx.sessions.create(SessionId('creation-window-child'), {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    pending.append('turn/start', { turn: 1 })
+    // The creation window: the establishing provider has not appended the
+    // descriptor yet, so the row is omitted rather than diagnosed.
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
+  })
+
+  it('lists a one-shot child with its durable creation label', async () => {
+    const { ctx, parent } = await setup([])
+    const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents({
+      version: SUBAGENT_DESCRIPTOR_VERSION,
+      mode: 'one-shot',
+      provider: 'spawn',
+      label: 'labeled one-shot',
+    }))
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
+      {
+        kind: 'child', id: labeled, mode: 'one-shot', label: 'labeled one-shot',
+        activity: 'inactive', hasChildren: false,
+      },
+    ])
   })
 
   it('reports a live child as running while keeping settled siblings complete', async () => {
@@ -256,7 +336,7 @@ describe('SubagentService.listChildren', () => {
     })
   })
 
-  it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
+  it('lists the last descriptor when a log carries more than one', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
     const healthy = await startChild(ctx, parent, 'healthy sibling')
     const events = childEvents(descriptorPayload('twice'))
@@ -267,22 +347,169 @@ describe('SubagentService.listChildren', () => {
       data: descriptorPayload('twice again'),
     } as SessionEvent)
     events[4] = { ...events[4]!, seq: 4 }
-    const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
+    const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, events)
+    // The last-wins projection fold serves the final descriptor's identity; a
+    // repeated descriptor is not a per-child corruption diagnostic.
+    const entries = await ctx.subagents.listChildren(parent.id)
+    expect(entries).toContainEqual({
+      kind: 'child', id: doubled, label: 'twice again', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
+    expect(entries).toContainEqual({
+      kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
+  })
+
+  it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => {
+    const { ctx, parent } = await setup([])
+    const liveId = SessionId('invalidated-live-child')
+    const live = ctx.sessions.create(liveId, {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    live.append('turn/start', { turn: 1 })
+    live.append('subagent/descriptor', descriptorPayload('was valid'))
+    expect(ctx.sessionProjections.snapshot(live).values.subagent)
+      .toEqual({ mode: 'continuable', label: 'was valid', seq: 1 })
+    // Last-wins: the malformed follow-up resets the identity to the sentinel.
+    live.append(
+      'subagent/descriptor',
+      { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never,
+    )
+    const values = ctx.sessionProjections.snapshot(live).values
+    expect(values.subagent).toBeNull()
+    // The sentinel survives a JSON push frame; an undefined field would be
+    // dropped there and a consumer would keep the stale identity forever.
+    const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown>
+    expect('subagent' in wired).toBe(true)
+    expect(wired['subagent']).toBeNull()
+    // The listing reads the same null as no value: running → omitted.
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
+  })
+
+  it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => {
+    const { ctx, parent } = await setup([])
+    const events = childEvents(descriptorPayload('was valid'))
+    events.splice(3, 0, {
+      type: 'subagent/descriptor',
+      seq: 3,
+      time: 3,
+      data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 },
+    } as SessionEvent)
+    events[4] = { ...events[4]!, seq: 4 }
+    const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', {
       parentSession: parent.id,
       origin: 'subagent',
     }, events)
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
+      { kind: 'diagnostic', id: invalidated, reason: 'corrupt' },
+    ])
+  })
+
+  it('serves a cached own-suffix identity directly without inspection', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('disk label')))
+    // seq 2 >= seedLength 0: the cached identity provably comes from the
+    // child's own suffix, so it is final and the log is never re-read — the
+    // divergent label proves the row, not the log, produced the entry.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+      asOfSeq: 2,
+      values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
+    })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: child, label: 'cached own', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    // A fork child: the seed replays the ancestor's descriptor (seq 2), and
+    // the child's own descriptor arrives in its first own turn (seq 5).
+    const seed = childEvents(descriptorPayload('ancestor label'))
+    const events = [
+      ...seed,
+      { type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
+      { type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') },
+      { type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', {
+      parentSession: parent.id,
+      seedLength: seed.length,
+      origin: 'subagent',
+    }, events)
+    // A creation-window checkpoint carried the ANCESTOR identity: its seq 2
+    // fails the own-suffix gate (< seedLength 4), so preparation rules.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+      asOfSeq: 2,
+      values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
+    })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
+  it.each([
+    ['version', (meta: SessionHeader): SessionHeader => ({ ...meta, version: meta.version + 1 })],
+    ['id', (meta: SessionHeader): SessionHeader => ({ ...meta, id: SessionId('another-lifecycle') })],
+    ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })],
+    ['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })],
+    ['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })],
+    ['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })],
+    ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })],
+  ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => {
+    const { ctx, parent } = await setup([textResponse('done')])
+    const healthy = await startChild(ctx, parent, 'healthy sibling')
+    const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('reborn child')))
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+      const result = await original(sessionId, signal)
+      if (sessionId !== reborn) return result
+      // The id was re-published as a different lifecycle after enumeration.
+      return { ...result, meta: mutate(result.meta) }
+    }
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
+    expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
     expect(entries).toContainEqual({
       kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
     })
   })
 
-  it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => {
+  it('lets preparation rule when the cache serves the null sentinel', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('actually valid')))
+    // A stale cached sentinel must not out-rank the authoritative re-fold.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
+  it('maps a child rejected by persistence inspection to unavailable', async () => {
     const { ctx, parent } = await setup([])
-    // The surface-eligible user/message lacks its required surfaceOp. The
-    // first-party persistence inspection rejects before session-query can fold it.
+    // The surface-eligible user/message lacks its required surfaceOp, so the
+    // first-party inspection rejects before any projection fold can run.
     const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
       parentSession: parent.id,
       origin: 'subagent',
@@ -297,7 +524,7 @@ describe('SubagentService.listChildren', () => {
       { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
     ] as SessionEvent[])
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
+    expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }])
   })
 
   it('diagnoses a malformed descriptor payload as corrupt', async () => {
@@ -310,28 +537,36 @@ describe('SubagentService.listChildren', () => {
     expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
   })
 
-  it('diagnoses an unknown descriptor version as unsupported', async () => {
+  it('diagnoses an unknown descriptor version as corrupt', async () => {
     const { ctx, parent } = await setup([])
     const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
       parentSession: parent.id,
       origin: 'subagent',
     }, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
+    // The projection fold does not distinguish an unrecognized version from
+    // other invalid descriptors: both serve no identity, and a settled
+    // no-value candidate is corrupt.
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
+    expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }])
   })
 
-  it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
+  it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => {
     const { ctx, parent } = await setup([])
-    // A fork child whose seed replays a parent log containing a descriptor:
-    // the seed's descriptor is the ANCESTOR's, not this child's.
+    // The last-wins fold serves a seed-replayed ancestor descriptor until the
+    // child's own descriptor overrides it (known deviation #1 in the design).
     const seed = childEvents(descriptorPayload('ancestor label'))
-    await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
+    const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
       parentSession: parent.id,
       seedLength: seed.length,
       origin: 'subagent',
     }, seed)
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([])
+    expect(entries).toEqual([
+      {
+        kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable',
+        activity: 'inactive', hasChildren: false,
+      },
+    ])
   })
 
   it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
@@ -354,103 +589,85 @@ describe('SubagentService.listChildren', () => {
     ])
   })
 
-  it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
+  it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
-    const childId = await startChild(ctx, parent, 'flaky storage')
-    const query = ctx.get('sessionQuery')!
-    const originalListEvents = query.listEvents.bind(query)
-    query.listEvents = (sessionId) => {
-      if (sessionId === childId) {
-        return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
-      }
-      return originalListEvents(sessionId)
-    }
+    ctx.sessionProjections.register(hostileProjectionDefinition)
+    const healthy = await startChild(ctx, parent, 'healthy sibling')
+    const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('poison me')))
+    // The subagent unit itself folds this child cleanly; the FOREIGN unit's
+    // view throws, and that damage stays contained to the one child.
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
+    expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' })
+    expect(entries).toContainEqual({
+      kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
   })
 
-  it.each([
-    ['session', 'SESSION_QUERY_SESSION_NOT_FOUND'],
-    ['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'],
-  ] as const)('maps a missing child %s to unavailable', async (_target, code) => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    const childId = await startChild(ctx, parent, 'vanishing child')
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () =>
-      Promise.reject(new SessionQueryError('gone', code))
+  it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => {
+    const { ctx, parent } = await setup([])
+    ctx.sessionProjections.register(hostileProjectionDefinition)
+    const poisonedId = SessionId('live-poisoned-child')
+    const poisoned = ctx.sessions.create(poisonedId, {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    poisoned.append('turn/start', { turn: 1 })
+    poisoned.append('subagent/descriptor', descriptorPayload('poison me'))
+    const healthyId = SessionId('live-healthy-child')
+    const healthy = ctx.sessions.create(healthyId, {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    healthy.append('turn/start', { turn: 1 })
+    healthy.append('subagent/descriptor', descriptorPayload('live healthy'))
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
+    expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' })
+    expect(entries).toContainEqual({
+      kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable',
+      activity: 'running', hasChildren: false,
+    })
   })
 
-  it('maps an invalid child surface to corrupt', async () => {
+  it('fails the whole enumeration when the persisted listing itself fails', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
-    const childId = await startChild(ctx, parent, 'invalid surface')
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () =>
-      Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
-
-    const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
+    await startChild(ctx, parent, 'never listed')
+    ctx.sessionPersistence.list = () => Promise.reject(new Error('backend listing failed'))
+    // Without any abort in flight, the original backend failure propagates
+    // as the operation failure — no cancellation mapping, no diagnostic rows.
+    await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('backend listing failed')
   })
 
-  it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
+  it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
-    const childId = await startChild(ctx, parent, 'reparented child')
-    const query = ctx.get('sessionQuery')!
-    const originalReadEvent = query.readEvent.bind(query)
-    query.readEvent = async (request) => {
-      const window = await originalReadEvent(request)
-      return {
-        ...window,
-        session: { ...window.session, parentSession: SessionId('someone-else') },
+    const healthy = await startChild(ctx, parent, 'healthy sibling')
+    const flaky = await authorChild(ctx, '00000000-0000-4000-8000-00000000f1a7', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('flaky storage')))
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = (sessionId, signal) => {
+      if (sessionId === flaky) {
+        return Promise.reject(new Error('backend read failed'))
       }
+      return original(sessionId, signal)
     }
-    const entries = await ctx.subagents.listChildren(parent.id)
-    // The exact read's conflicting immutable header is per-child corruption.
-    expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
-  })
-
-  it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    const childId = await startChild(ctx, parent, 'shifted log')
-    const query = ctx.get('sessionQuery')!
-    const originalReadEvent = query.readEvent.bind(query)
-    query.readEvent = async (request) => {
-      const window = await originalReadEvent(request)
-      return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
-    }
-    const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
-  })
-
-  it('fails the whole call when the initial trace fails', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'never listed')
-    const query = ctx.get('sessionQuery')!
-    query.traceSession = () =>
-      Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
-    await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
-      expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
-    )
-  })
-
-  it('propagates an unrecognized per-child failure as an operation failure', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'strange failure')
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () => Promise.reject(new Error('not a query failure'))
-    await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
-  })
-
-  it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'misconfigured query')
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () =>
-      Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
-    await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
-      expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
-    )
+    // Per-child isolation: the failed child degrades to one diagnostic while
+    // the healthy sibling stays complete.
+    const degraded = await ctx.subagents.listChildren(parent.id)
+    expect(degraded).toContainEqual({ kind: 'diagnostic', id: flaky, reason: 'unavailable' })
+    expect(degraded).toContainEqual({
+      kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
+    // Nothing is memoized: with the backend healthy again, the next listing
+    // folds the same child to its identity.
+    ctx.sessionPersistence.inspect = original
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
+      kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    })
   })
 
   it('lists compacted and uncompacted children identically', async () => {
@@ -492,19 +709,18 @@ describe('SubagentService.listChildren', () => {
     ])
   })
 
-  it('reports an origin-classified grandchild without reading its events', async () => {
+  it('reports an origin-classified grandchild without inspecting it', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
     const childId = await startChild(ctx, parent, 'direct child')
     const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
       parentSession: childId,
       origin: 'subagent',
     }, childEvents(descriptorPayload('grandchild')))
-    const query = ctx.get('sessionQuery')!
-    const originalListEvents = query.listEvents.bind(query)
     const inspected: SessionId[] = []
-    query.listEvents = (sessionId) => {
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = (sessionId, signal) => {
       inspected.push(sessionId)
-      return originalListEvents(sessionId)
+      return original(sessionId, signal)
     }
     const entries = await ctx.subagents.listChildren(parent.id)
     expect(entries).toEqual([
@@ -513,10 +729,112 @@ describe('SubagentService.listChildren', () => {
         activity: 'inactive', hasChildren: true,
       },
     ])
+    // The grandchild contributes only its header to the hasChildren hint.
     expect(inspected).toContain(childId)
     expect(inspected).not.toContain(grandchildId)
   })
 
+  it('inspects each cold child exactly once and a live child never', async () => {
+    const { ctx, parent } = await setup([textResponse('done')])
+    const coldStarted = await startChild(ctx, parent, 'cold started child')
+    const coldAuthored = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('cold authored child')))
+    const liveId = SessionId('live-mixed-child')
+    const live = ctx.sessions.create(liveId, {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    live.append('turn/start', { turn: 1 })
+    live.append('subagent/descriptor', descriptorPayload('live mixed child'))
+
+    const inspected: SessionId[] = []
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = (sessionId, signal) => {
+      inspected.push(sessionId)
+      return original(sessionId, signal)
+    }
+    const entries = await ctx.subagents.listChildren(parent.id)
+    expect(entries).toHaveLength(3)
+    // The cost model: one inspection per cold child, none for a live child,
+    // whose identity is served from the registry's watermark cache.
+    expect(inspected.filter(id => id === coldStarted)).toHaveLength(1)
+    expect(inspected.filter(id => id === coldAuthored)).toHaveLength(1)
+    expect(inspected).not.toContain(liveId)
+  })
+
+  it('serves a cold child from the projection cache without any inspection', async () => {
+    const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true })
+    const childId = await startChild(ctx, parent, 'cached child')
+    // The child's turn/end and disposal are the cache's mandatory checkpoint
+    // points; both writes are fail-soft asynchronous, so wait for the row.
+    const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
+    await vi.waitFor(() => {
+      expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
+    }, { timeout: 5_000 })
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).not.toHaveBeenCalled()
+  })
+
+  it('falls back to inspection when the cache serves no identity for the child', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('uncached child')))
+    const expected = [{
+      kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }]
+    // No stored row at all for a foreign child this process never ran.
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
+    expect(inspect).toHaveBeenCalledTimes(1)
+    // A stored row whose cut predates the descriptor: the subagent key is
+    // absent from the served values, and preparation still rules.
+    ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
+    expect(inspect).toHaveBeenCalledTimes(2)
+  })
+
+  it('takes the preparation rung directly when no projection cache is mounted', async () => {
+    const { ctx, parent } = await setup([])
+    expect(ctx.get('sessionProjectionCache')).toBeUndefined()
+    const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('uncacheable child')))
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
+  it('silently falls through to preparation when the cache read throws', async () => {
+    const { ctx, parent } = await setup([], { projectionCache: true })
+    const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('recovered child')))
+    ctx.sessionProjectionCache.cachedSnapshot = () => {
+      // A poisoned stored row (any unit's) detonates at view time; the cache
+      // is derived data, so its failure must not become a verdict.
+      throw new Error('poisoned cache row')
+    }
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
+      kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
+      activity: 'inactive', hasChildren: false,
+    }])
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
   it('does not count an ordinary grandchild without subagent origin', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
     const childId = await startChild(ctx, parent, 'direct child')
@@ -550,37 +868,25 @@ describe('SubagentService.listChildren', () => {
     }])
   })
 
-  it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => {
-    const { ctx, parent } = await setup([textResponse('one'), textResponse('two')])
-    await startChild(ctx, parent, 'first child')
-    await startChild(ctx, parent, 'second child')
+  it('a pre-aborted signal stops before any persistence read', async () => {
+    const { ctx, parent } = await setup([])
     const controller = new AbortController()
-    const query = ctx.get('sessionQuery')!
-    const originalListEvents = query.listEvents.bind(query)
-    let inspected = 0
-    query.listEvents = (sessionId) => {
-      inspected += 1
-      // Cancel while the first candidate's read is in flight: the loop's next
-      // between-candidates checkpoint must stop before the second read.
-      controller.abort()
-      return originalListEvents(sessionId)
-    }
+    controller.abort()
+    ctx.sessionPersistence.list = () => Promise.reject(new Error('must not be called'))
     await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
       expect.objectContaining({ code: 'CANCELLED' }) as Error,
     )
-    expect(inspected).toBe(1)
   })
 
-  it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
+  it('forwards cancellation to the persisted listing and reports the stable subagent error', async () => {
     const { ctx, parent } = await setup([])
     const controller = new AbortController()
-    const query = ctx.get('sessionQuery')!
     const entered = Promise.withResolvers<undefined>()
-    query.traceSession = (_sessionId, signal) => {
+    ctx.sessionPersistence.list = (signal) => {
       entered.resolve(undefined)
       return new Promise((_resolve, reject) => {
         signal?.addEventListener('abort', () => {
-          reject(new Error('query trace aborted'))
+          reject(new Error('backend listing aborted'))
         }, { once: true })
       })
     }
@@ -592,17 +898,19 @@ describe('SubagentService.listChildren', () => {
     )
   })
 
-  it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'cancelled exact read')
+  it('forwards cancellation to a cold inspection and reports the stable subagent error', async () => {
+    const { ctx, parent } = await setup([])
+    await authorChild(ctx, '00000000-0000-4000-8000-00000000ce11', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('cancelled cold read')))
     const controller = new AbortController()
-    const query = ctx.get('sessionQuery')!
     const entered = Promise.withResolvers<undefined>()
-    query.readEvent = (_request, signal) => {
+    ctx.sessionPersistence.inspect = (_sessionId, signal) => {
       entered.resolve(undefined)
       return new Promise((_resolve, reject) => {
         signal?.addEventListener('abort', () => {
-          reject(new Error('query read aborted'))
+          reject(new Error('backend read aborted'))
         }, { once: true })
       })
     }
@@ -614,56 +922,43 @@ describe('SubagentService.listChildren', () => {
     )
   })
 
-  it('stops after a per-child read when the signal aborts mid-inspection', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'cancelled mid-read')
+  it('an abort observed after a cold inspection resolves cannot become a successful result', async () => {
+    const { ctx, parent } = await setup([])
+    await authorChild(ctx, '00000000-0000-4000-8000-00000000ce12', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('cancelled mid-listing')))
     const controller = new AbortController()
-    const query = ctx.get('sessionQuery')!
-    const originalReadEvent = query.readEvent.bind(query)
-    let exactReads = 0
-    query.readEvent = async (request) => {
-      exactReads += 1
-      const window = await originalReadEvent(request)
+    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+      const result = await original(sessionId, signal)
       controller.abort()
-      return window
+      return result
     }
-    // The post-read checkpoint throws a subagent error, which is not a
-    // session-query failure and therefore propagates instead of becoming a
-    // per-child diagnostic.
+    // The post-read checkpoint throws the stable subagent error instead of
+    // interpreting the fully-read log as a successful listing.
     await expect(ctx.subagents.listChildren(parent.id, controller.signal))
       .rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
-    expect(exactReads).toBe(1)
   })
 
-  it('a mapped per-child failure during an abort cannot become a successful result', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'aborted behind a diagnostic')
+  it('a cold inspection failure during an abort cannot become an unavailable diagnostic', async () => {
+    const { ctx, parent } = await setup([])
+    await authorChild(ctx, '00000000-0000-4000-8000-00000000ce13', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('aborted behind a failure')))
     const controller = new AbortController()
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () => {
-      // The read fails with a diagnostic-mapped code while the caller aborts:
-      // cancellation normalization must fail the scan rather than return a
-      // one-diagnostic success.
+    ctx.sessionPersistence.inspect = () => {
+      // The read fails while the caller aborts: cancellation normalization
+      // must fail the listing rather than return a one-diagnostic success.
       controller.abort()
-      return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
+      return Promise.reject(new Error('backend read failed'))
     }
     await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
       expect.objectContaining({ code: 'CANCELLED' }) as Error,
     )
   })
 
-  it('a pre-aborted signal stops before any candidate read', async () => {
-    const { ctx, parent } = await setup([textResponse('done')])
-    await startChild(ctx, parent, 'never read')
-    const controller = new AbortController()
-    controller.abort()
-    const query = ctx.get('sessionQuery')!
-    query.listEvents = () => Promise.reject(new Error('must not be called'))
-    await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
-      expect.objectContaining({ code: 'CANCELLED' }) as Error,
-    )
-  })
-
   it('returns an empty array for a parent with no children', async () => {
     const { ctx, parent } = await setup([])
     await ctx.sessions.flush(parent.session)
@@ -671,9 +966,9 @@ describe('SubagentService.listChildren', () => {
   })
 
   it('SubagentError from listChildren is typed with its stable code', async () => {
-    const { ctx, parent } = await setup([], { sessionQuery: false })
+    const { ctx, parent } = await setup([], { sessionProjections: false })
     const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
     expect(caught).toBeInstanceOf(SubagentError)
-    expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
+    expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
   })
 })

+ 0 - 13
packages/subagent/subagent/tests/optional-session-query.spec.ts

@@ -1,13 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-
-describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
-  it('loads ordinary subagent operations without evaluating the optional query package', async () => {
-    vi.doMock('@deepseek-ai/dsh-session-query', () => {
-      throw new Error('optional session-query runtime was loaded eagerly')
-    })
-
-    const subagent = await import('../src/index.ts')
-
-    expect(subagent.SubagentService).toBeTypeOf('function')
-  })
-})

+ 8 - 4
packages/subagent/subagent/tests/timing-projection.spec.ts

@@ -23,11 +23,15 @@ describe('subagent timing projection', () => {
     await ctx.plugin(SessionProjectionRegistry)
     const serviceFiber = await ctx.plugin(SubagentService)
 
-    expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
-      .toEqual({ settledMs: 0 })
+    const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
+    expect(before.subagentTiming).toEqual({ settledMs: 0 })
+    // The identity unit registers alongside timing; an empty log serves its
+    // serializable null sentinel.
+    expect(before.subagent).toBeNull()
     await serviceFiber.dispose()
-    expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
-      .toBeUndefined()
+    const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
+    expect(after.subagentTiming).toBeUndefined()
+    expect(after.subagent).toBeUndefined()
   })
 
   it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {

+ 2 - 2
packages/subagent/subagent/tsconfig.json

@@ -30,10 +30,10 @@
       "path": "../../session-persistence/session-persistence"
     },
     {
-      "path": "../../session-query/session-query"
+      "path": "../../session-projection/session-projection"
     },
     {
-      "path": "../../session-projection/session-projection"
+      "path": "../../session-projection/session-projection-cache"
     },
     {
       "path": "../../tasks/tasks"

+ 2 - 2
packages/subagent/tool-subagent-control/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
-README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca
-README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69
+README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
+README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1

+ 1 - 1
packages/subagent/tool-subagent-control/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
+The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
 
 The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered.
 

+ 1 - 1
packages/subagent/tool-subagent-control/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
+可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
 
 本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
 

+ 1 - 7
packages/subagent/tool-subagent-control/package.json

@@ -33,16 +33,10 @@
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
-    "@deepseek-ai/dsh-session-query": "^0.0.1",
     "@deepseek-ai/dsh-subagent": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
-  "peerDependenciesMeta": {
-    "@deepseek-ai/dsh-session-query": {
-      "optional": true
-    }
-  },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
@@ -52,7 +46,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
-    "@deepseek-ai/dsh-session-query": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 5 - 8
packages/subagent/tool-subagent-control/src/list-agents.ts

@@ -1,20 +1,17 @@
 /**
  * The globally named `list_agents` tool: a thin model-facing adapter over
- * the continuable projection of `ctx.subagents.listChildren()`. It is
- * separately loadable from the
- * root `send_message` plugin because it additionally requires the session
- * query service — a deployment may use `send_message` without loading session
- * query, and this plugin remains inactive until that service is available.
+ * the continuable projection of `ctx.subagents.listChildren()`. It stays
+ * separately loadable from the root `send_message` plugin so a deployment
+ * can register `send_message` without exposing the list tool.
  * @module @deepseek-ai/dsh-tool-subagent-control/list-agents
  */
 
 import type { Context } from 'cordis'
 import { defineTool } from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-session-query'
 import type {} from '@deepseek-ai/dsh-subagent'
 
 export const name = 'tool-subagent-list-agents'
-export const inject = ['tools', 'subagents', 'sessionQuery']
+export const inject = ['tools', 'subagents']
 
 type ListAgentsEntry =
   | {
@@ -31,7 +28,7 @@ type ListAgentsEntry =
 
 /**
  * Register the `list_agents` tool.
- * @param ctx - context carrying the tool registry, subagent service, and session query.
+ * @param ctx - context carrying the tool registry and subagent service.
  */
 export function apply(ctx: Context): void {
   ctx.tools.register(defineTool({

+ 4 - 5
packages/subagent/tool-subagent-control/tests/list-agents.spec.ts

@@ -8,11 +8,11 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
-import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
 import * as tool from '../src/list-agents.ts'
 
 const testToolSignal = new AbortController().signal
@@ -29,9 +29,9 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
   roots.push(root)
   await ctx.plugin(JsonlSessionPersistence, { root })
   await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
-  await ctx.plugin(TestSessionQueryService)
   await ctx.plugin(tool)
   ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
   const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
@@ -177,17 +177,16 @@ describe('dsh-tool-subagent-control/list-agents', () => {
     await mountAgentLoopTestDependencies(ctx)
     await ctx.plugin(AgentLoop, { agents: [] })
     await ctx.plugin(SubagentService)
-    await ctx.plugin(TestSessionQueryService)
     const fiber = await ctx.plugin(tool)
     expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
     await fiber.dispose()
     expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
   })
 
-  it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
+  it('has the namespace-plugin export shape', () => {
     expect('default' in tool).toBe(false)
     expect(tool.name).toBe('tool-subagent-list-agents')
-    expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
+    expect(tool.inject).toEqual(['tools', 'subagents'])
     expect(typeof tool.apply).toBe('function')
   })
 })

+ 2 - 0
packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts

@@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -27,6 +28,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
   roots.push(root)
   await ctx.plugin(JsonlSessionPersistence, { root })
   await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(tool)

+ 0 - 3
packages/subagent/tool-subagent-control/tsconfig.json

@@ -26,9 +26,6 @@
     {
       "path": "../subagent"
     },
-    {
-      "path": "../../session-query/session-query"
-    },
     {
       "path": "../../support/invariants"
     }

+ 16 - 7
pnpm-lock.yaml

@@ -418,6 +418,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:*
         version: link:../packages/session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:*
+        version: link:../packages/session-projection/session-projection
       '@deepseek-ai/dsh-session-query':
         specifier: workspace:*
         version: link:../packages/session-query/session-query
@@ -943,6 +946,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection
       '@deepseek-ai/dsh-session-query-sqlite':
         specifier: workspace:^
         version: link:../../session-query/session-query-sqlite
@@ -1188,9 +1194,6 @@ importers:
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../host/webserver
-      '@deepseek-ai/dsh-session-projection':
-        specifier: workspace:^
-        version: link:../../session-projection/session-projection
       '@deepseek-ai/dsh-session-projection-cache':
         specifier: workspace:^
         version: link:../../session-projection/session-projection-cache
@@ -5190,9 +5193,15 @@ importers:
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session-projection/session-projection
-      '@deepseek-ai/dsh-session-query':
+      '@deepseek-ai/dsh-session-projection-cache':
         specifier: workspace:^
-        version: link:../../session-query/session-query
+        version: link:../../session-projection/session-projection-cache
+      '@deepseek-ai/dsh-storage':
+        specifier: workspace:^
+        version: link:../../storage/storage
+      '@deepseek-ai/dsh-storage-domain':
+        specifier: workspace:^
+        version: link:../../storage/storage-domain
       '@deepseek-ai/dsh-tasks':
         specifier: workspace:^
         version: link:../../tasks/tasks
@@ -5590,9 +5599,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
-      '@deepseek-ai/dsh-session-query':
+      '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
-        version: link:../../session-query/session-query
+        version: link:../../session-projection/session-projection
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../subagent

+ 4 - 3
scripts/gen-tool-catalog.ts

@@ -14,6 +14,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createScope } from '@deepseek-ai/dsh-scope'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
 import GoalService from '@deepseek-ai/dsh-goal'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -401,19 +402,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
       list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
       send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
     },
-    requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionQuery (list_agents only)'],
+    requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionProjections (list_agents catalog rows)'],
     writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
     async mount(ctx) {
       await ctx.plugin(SubagentService)
       await ctx.plugin(LocalTaskService)
       await ctx.plugin(AgentRegistry)
       await ctx.plugin(SessionStore)
-      await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
+      await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(ToolSubagentControl)
       await ctx.plugin(ToolSubagentListAgents)
     },
     note:
-      'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).',
+      'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).',
   },
   {
     pkg: '@deepseek-ai/dsh-tool-subagent-report',

Некоторые файлы не были показаны из-за большого количества измененных файлов