diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
index ad8ffb6167..78e0976e24 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
-2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
+2026-08-31-released-session-format-migrations.md: 11c35eb80862ccebbead566164e61b5f4a7792fb
+2026-08-31-released-session-format-migrations.zh.md: a79bf77ab19ae85bc4dc9c6a942022b8bd198378
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
index 273b51ac7c..11c35eb808 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
@@ -105,7 +105,7 @@ Preparation forwards cancellation through source reads and observes it at the ex
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
-The bulk V4 migration command shares one JSONL persistence Context across a bounded job queue, retaining the backend’s two-Worker verification limit and cache for reusing recently decoded logs. A child’s concurrent publication can change its parent’s selected child generation; the ordinary revision check refuses that attempt. The command defers only this source-change error for one sequential retry after the initial queue drains, collecting fresh evidence without relaxing publication checks or retrying unrelated failures.
+The bulk V4 migration command explicitly requests each Session through a bounded queue and shares one JSONL Context, including its two-Worker verification limit. Each publication validates only its own source. The command may retry a source-change refusal after the queue drains; a child publication does not invalidate a parent preparation.
### Durable format and publication rules
@@ -131,20 +131,20 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
### Parent catalog prerequisites
-V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
+Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
-Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
+Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
-The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
+Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
### Catalog scan measurements
The package-local [diagnostic](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts) measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.
-On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.
+The table records the eager catalog-scan implementation on macOS arm64 with Node v26.0.0 before Session-local runtime migration. The 1,000-unrelated medians were 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These historical measurements explain why metadata-only access was insufficient while preparation still decoded child bodies; they are not current timings or cross-host thresholds.
| Unrelated Sessions | Historical stat | Cold read | Memo read | Publish | Current warm read |
|---:|---:|---:|---:|---:|---:|
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
index 69132ec176..a79bf77ab1 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
@@ -105,7 +105,7 @@ Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms D
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
-批量 V4 迁移命令在有界任务队列中共享一个 JSONL persistence Context,保留 backend 的双 Worker 校验上限,以及复用近期已解码日志的缓存。子 Session 并发发布可能改变父 Session 选中的子代际;普通修订检查会拒绝这次尝试。命令只把这种源变化错误延后到初始队列清空后串行重试一次,重新收集证据,不放宽发布检查,也不重试无关失败。
+批量 V4 迁移命令通过有界队列显式请求各个会话,共享一个 JSONL Context 及其两个 Worker 的校验限制。每次发布只校验自身源文件。命令可在队列排空后重试源变化拒绝;子会话发布不会使父会话准备结果失效。
### Durable format 与 publication 规则
@@ -131,20 +131,20 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
### 父目录前置事实
-V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
+原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
-历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
+原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
-通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
+原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
### 目录扫描测量
包内[诊断脚本](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts)测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。
-以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。
+下表记录会话独立运行时迁移之前,macOS arm64、Node v26.0.0 上主动 catalog 扫描实现的测量。1,000 个无关会话时,中位数为 stat 84.14 ms、冷读 237.97 ms、缓存读 110.98 ms、发布 268.46 ms、同进程当前格式读取 0.41 ms。这些历史测量说明:当准备仍解码子正文时,仅优化元数据访问并不足够;它们不是当前耗时或跨主机阈值。
| 无关 Session | 历史 stat | 冷读 | 缓存读取 | 发布 | 当前格式热读 |
|---:|---:|---:|---:|---:|---:|
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
index 59e3f84862..5f7de01cfd 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
-2026-09-01-parent-owned-subagent-catalog.md: 536ec33148bd675a974622b98ee23de812691dc0
-2026-09-01-parent-owned-subagent-catalog.zh.md: ec78ff9e4b7d54a7309baf21c383ec5ff83a19c8
+2026-09-01-parent-owned-subagent-catalog.md: 1c65813cd92aebbd2e45acc6b1f121aba442646d
+2026-09-01-parent-owned-subagent-catalog.zh.md: 7eded398ca53997ad80c3910aad333b4a1476286
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
index 536ec33148..1c65813cd9 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
@@ -44,6 +44,8 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
-Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
+Session observations and client snapshots expose durable catalog facts through `projections.values.subagentCatalog`. The change feed publishes complete lists, so D creations can incur O(D²) cumulative view work. Historical children may lack these facts: `listChildren()` supplements one parent observation with header-only discovery and returns unresolved rows without reading child bodies. The Web derives the same missing entries from its existing Session list. This preserves the catalog as the sole durable creation record while accepting O(N) header enumeration for explicit service discovery. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
+
+The [Session-local migration decision](../bug-fix/2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
index ec78ff9e4b..7eded398ca 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
@@ -44,6 +44,8 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
-Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
+会话观察和客户端快照通过 `projections.values.subagentCatalog` 暴露持久化 catalog 事实。变化流发布完整列表,因此 D 次创建可能累计 O(D²) 视图工作。历史子会话可能缺少这些事实:`listChildren()` 在一次父观察之外补充仅头部发现,返回未解析条目,不读子正文。Web 从已有会话列表派生同类缺失条目。这保留 catalog 作为唯一持久化创建记录,同时接受显式服务发现的 O(N) 头部枚举成本。[Web 投影消费](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 负责浏览器加载与同步。
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
+
+[会话独立迁移决策](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml
new file mode 100644
index 0000000000..dcd77fa51d
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.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/bug-fix/2026-09-19-session-local-subagent-migration.md
+2026-09-19-session-local-subagent-migration.md: c3da2812ac2d48fe4894b62b3b5743664564633a
+2026-09-19-session-local-subagent-migration.zh.md: 43875dd8cd34635d33f711745aa9725641e4cec3
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md
new file mode 100644
index 0000000000..c3da2812ac
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md
@@ -0,0 +1,33 @@
+# Agent Note: Migrate each Session independently of its children
+
+Status: implemented
+
+English | [中文](2026-09-19-session-local-subagent-migration.zh.md)
+
+## Problem
+
+Runtime V3→V4 preparation collected every direct child's descriptor to complete the parent catalog. This decoded whole child logs before opening the parent, and unrelated unreadable headers could prevent complete membership classification. A corrupt child therefore blocked valid parent history. Historical revisions also fingerprinted the entire root, invalidating a parent's cached preparation when unrelated files changed.
+
+## Decision
+
+JSONL migrates only the Session explicitly opened by a reader or writer. It preserves existing catalog facts and does not read child bodies or collect cross-session evidence. Read opens prepare current logical events without publishing; write opens validate the selected source and exclusively publish its immutable successor. Public revisions and preparation reuse depend only on the selected file. Predecessor generations remain byte-identical.
+
+Discovery combines parent catalog facts with header relationships. The subagent service returns unresolved direct-child entries when no catalog fact exists; the Web derives them from its existing Session list. Menu and branch refreshes enumerate headers without requesting child projections. Selecting a child opens its own history, validates its direct parent and descriptor, and resolves mode and label. Until then the address grants no continuation authority and the composer stays read-only. A child open failure remains local. Explicit programmatic reads and continuation requests also migrate their selected Session on demand; explicit descendant inspection and bulk migration still read the Sessions they request.
+
+The catalog remains the durable source of successful creation facts. Unresolved discovery rows are neither persisted events nor projection values. No new Session format or persistent index is needed. Offline fixture preparation and Preview packing may still explicitly supply child evidence for catalog completion; their caller already owns that corpus.
+
+This partially supersedes runtime catalog prerequisites and corpus revisions in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md), the discovery-only assumption in [parent catalogs](../architecture/2026-09-01-parent-owned-subagent-catalog.md), and unopened-branch reads in [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md). Their format, creation, ordering, and transport decisions remain active. [Incomplete evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md) continues to govern optional offline completion.
+
+## Alternatives considered
+
+**Catch child failures and continue backfill.** This still decodes every healthy child, hides failures, and permanently omits discovery facts when the parent successor is published.
+
+**Supply an empty child set without changing discovery.** This removes migration coupling but makes historical children disappear from catalog-only consumers.
+
+**Persist placeholder catalog events or another child index.** Unknown mode and label are not successful creation facts. A second persistent index adds write coordination and repair without improving header-based navigation.
+
+## Consequences
+
+Explicit direct-child discovery now enumerates N headers in addition to materializing D catalog facts. It avoids all child-body decoding, while ordinary parent migration has no corpus-dependent preparation. Historical rows may initially show an id instead of a label and cannot promise resumability. Headers that cannot be decoded cannot contribute a navigation row, but do not block opening another known Session.
+
+Raw and compressed persistence regressions cover V0–V3, child corruption and future formats, independent publication, unchanged predecessors, and stable parent revisions across child mutations. Service tests verify only the parent is observed. Client and Host tests verify unresolved addresses, selected-child decoding, mode resolution, and ownership rejection. The shipped Web preset-migration snapshot round-trips parent and child generations with a corrupt sibling and checks that only explicitly opened Sessions migrate.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md
new file mode 100644
index 0000000000..43875dd8cd
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 独立于子会话迁移每个会话
+
+Status: implemented
+
+[English](2026-09-19-session-local-subagent-migration.md) | 中文
+
+## 问题
+
+运行时 V3→V4 准备收集每个直接子会话的 descriptor 以补全父 catalog。这在打开父会话之前解码完整子日志,而无关的不可读头部也可能阻止完整成员分类。因此,一个损坏子会话就会阻断有效父历史。历史版本标识还对整个根目录取指纹,无关文件变化也使父缓存准备失效。
+
+## 决策
+
+JSONL 只迁移读写方显式打开的会话。它保留已有 catalog 事实,不读取子正文或收集跨会话证据。读 open 准备当前逻辑事件但不发布;写 open 校验所选源并独占发布不可变后继。公开版本标识和准备复用只依赖所选文件。前代文件字节保持不变。
+
+发现机制合并父 catalog 事实与头部关系。没有 catalog 事实时,子代理服务返回未解析直接子条目;Web 从已有会话列表派生这些条目。菜单和分支刷新枚举头部,不请求子投影。选择子会话时打开自身历史,校验直接父归属和 descriptor,并解析模式与标签。此前地址不授予续接权限,输入区保持只读。子打开失败仅影响自身。显式程序读取和续接请求同样按需迁移选中会话;显式后代检查和批量迁移仍读取它们所请求的会话。
+
+Catalog 仍是成功创建事实的持久化来源。未解析发现行既不是持久化事件,也不是投影值。无需新会话格式或持久化索引。离线夹具准备和 Preview 打包仍可显式提供子证据来补全 catalog,因为调用者已拥有该语料库。
+
+本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的运行时 catalog 前提与全库版本标识、[父 catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)中的仅靠 catalog 发现假设,以及 [Web 投影消费](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)中的未打开分支读取。它们的格式、创建、排序和传输决策仍有效。[不完整证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)继续约束可选离线补全。
+
+## 考虑过的替代方案
+
+**捕获子错误并继续回填。** 这仍解码所有健康子会话、隐藏失败,并在父后继发布时永久省略发现事实。
+
+**传入空子集合而不改发现机制。** 这消除了迁移耦合,却让历史子会话从仅消费 catalog 的调用方消失。
+
+**持久化占位 catalog 事件或另建子索引。** 未知模式和标签不是成功创建事实。第二份持久化索引增加写协调和修复工作,并不能改善基于头部的导航。
+
+## 影响
+
+显式直接子发现除物化 D 条 catalog 事实外,还枚举 N 个头部。它避免所有子正文解码,而普通父迁移没有依赖全库的准备工作。历史行最初可能显示 id 而非标签,且不能承诺可续接。无法解码的头部不能贡献导航行,但不会阻止打开另一个已知会话。
+
+原始与压缩持久化回归覆盖 V0–V3、子损坏与未来格式、独立发布、前代不变,以及子变化期间父版本标识稳定。服务测试确认只观察父会话。Client 和 Host 测试验证未解析地址、选中子解码、模式解析与归属拒绝。正式 Web preset-migration 快照在损坏兄弟会话存在时往返父子代次,并检查只有显式打开的会话才迁移。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml
index 4ede5bd457..2d37c0209c 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
-2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92
-2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038
+2026-09-19-v3-incomplete-child-catalog-evidence.md: d9cab54d0d381667e840d666a48608298464441e
+2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 248cc0cc7f899837f06e2cc082b174e87d125262
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
index 22ca915024..d9cab54d0d 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
@@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi
## Decision
-The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
+Optional V3→V4 catalog completion for offline fixture preparation and Preview packing appends a missing parent entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new fact; existing parent entries and child events remain intact. Runtime JSONL migration does not collect this evidence and restores each Session independently, as specified in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
-Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures.
+Supplied child creation times must match existing parent entries. Mode, label, and descriptor fields are checked only for exactly one supported own descriptor. These evidence checks belong to explicit offline completion; child corruption or unsupported generations never prevent runtime parent migration.
## Alternatives considered
@@ -24,6 +24,8 @@ Known child creation times must still match existing parent entries. Mode/label
## Consequences
-A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
+A historical child without a usable descriptor remains discoverable from a readable parent-linked header even when no parent catalog entry exists. Runtime migration preserves that absence rather than inventing a fact. The child’s own open performs decoding and reports any failure locally; publishing a parent successor never requires successful child decoding.
-Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
+Pure completion and Preview tests retain unavailable-evidence, existing-entry, and conflict coverage. JSONL read/write regressions cover independent parent and child opens, stable parent revisions across child changes, and unchanged predecessor bytes.
+
+The [Session-local migration decision](./2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md
index c11e6de2c2..248cc0cc7f 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md
@@ -10,9 +10,9 @@ Status: implemented
## Decision
-V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
+离线夹具准备和 Preview 打包中的可选 V3→V4 catalog 补全,仅在恰好一个受支持的自身子 descriptor 提供发现字段时追加缺失父条目。其他 descriptor 数量或不支持的版本不贡献新事实,已有父条目和子事件保持完整。运行时 JSONL 迁移不收集这些证据,并按[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)独立恢复各会话。
-已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
+传入子创建时间必须匹配已有父条目。只有恰好一个受支持自身 descriptor 时才检查模式、标签和 descriptor 字段。这些证据检查属于显式离线补全;子会话损坏或不支持的代次不会阻止运行时父会话迁移。
## Alternatives considered
@@ -24,6 +24,8 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字
## Consequences
-没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
+即使没有父 catalog 条目,缺少可用 descriptor 的历史子会话仍可通过可读且关联父会话的头部被发现。运行时迁移保留该缺失状态,不虚构事实。子会话自身 open 执行解码并局部报告失败;发布父后继从不要求子解码成功。
-单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
+纯补全与 Preview 测试保留不可用证据、已有条目和冲突覆盖。JSONL 读写回归覆盖父子独立打开、子变化期间父版本标识稳定,以及前代字节不变。
+
+[会话独立迁移决策](./2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.i18n.yaml
index d589324465..f05b4787c1 100644
--- a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.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/simplification/2026-09-08-web-subagent-catalog-projections.md
-2026-09-08-web-subagent-catalog-projections.md: 766d33e581bcac53844b0836f23cda615858fc27
-2026-09-08-web-subagent-catalog-projections.zh.md: ef14db2530ed10a7aa34408c5a6fada6f0a28858
+2026-09-08-web-subagent-catalog-projections.md: f714418a8641b76228cf8723657205fbfda22133
+2026-09-08-web-subagent-catalog-projections.zh.md: c23b29f47795a6b8113d5be5f7a57c4713e203d9
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md
index 766d33e581..f714418a86 100644
--- a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md
@@ -10,9 +10,9 @@ The parent catalog projection already publishes complete membership through the
## Decision
-The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened catalog branches still use explicit reads.
+The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened branches use header relationships from the Session list instead of requesting child projections. Missing historical catalog entries remain non-durable unresolved navigation rows until the child is opened.
-Feature consumers select `subagentCatalog` from `projectionsBySession` and derive row activity from Session-list baselines and status events. Session summaries report Agent availability separately; Agent creation and disposal republish the existing summary event. Completed subagents retain their summaries and display projections after removal. Generic projection reads own loading and error state; parent availability comes from the Session list independently of membership. Opening a loaded catalog reuses its data; an initial failure can be retried. Reconnect cancels previous reads and starts fresh reads for requested catalogs. Session removal cancels its pending read and marks parent availability false; projection responses cannot change Agent availability.
+Feature consumers select `subagentCatalog` from `projectionsBySession`, supplement missing rows from parent-linked subagent summaries, and derive activity from Session-list baselines and status events. Header-discovered rows carry unresolved mode and grant no continuation authority. Menu and branch refreshes update only the lightweight Session list. Opening the selected child validates its descriptor and parent and resolves the mode; a failed open stays local to that child. Existing projection ordering, generic explicit projection reads, and independent parent availability remain unchanged.
The [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns durable creation facts and ordering. This decision supersedes the dedicated membership-refresh mechanism in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md); that note remains active for navigation, control, and presentation decisions.
@@ -29,3 +29,5 @@ Host summaries replace running and availability state; local create/fork placeho
Membership changes use the existing control stream without extra catalog notifications or reads. Whole-value projection publication still costs O(D) for D children; this change does not introduce delta transport. Initial reads return the observation's complete projection baseline, including values beyond the catalog, so they reuse standard baseline semantics at the cost of that additional initial payload. Session sequence ordering does not establish identity across reused Session ids.
Manager tests cover pushed membership, stale initial responses, lazy loading, status composition, retained completion metadata, retry, removal of empty catalogs, late create/fork responses, and reconnect cancellation. Host tests exercise the shared control stream and cold observations. The persisted-subagent Web scenario covers nested historical navigation and continuation through the shipped application.
+
+The [Session-local migration decision](../bug-fix/2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
diff --git a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md
index ef14db2530..c23b29f477 100644
--- a/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.zh.md
@@ -10,9 +10,9 @@ Status: implemented
## 决策
-Client 仅在标准逐 Session projection store 中保留目录成员关系。初始 `session.projections` 读取通过一次 live-preferred Session observation 返回完整 projection 基线。endpoint 接受任意 Session id,不含目录专用依赖或校验。值与序号游标来自同一次 observation。初始基线与实时 control 帧使用 store 既有的序号排序,因此旧响应不能替换新值。该 endpoint 不激活 Agent,也不采样子代理活动状态。打开会话时使用其 follow baseline,不再额外调度一次投影读取;未打开的目录分支仍使用显式读取。
+Client 仅在标准每会话投影存储中保留 catalog 事实。显式 `session.projections` 读取从一次优先存活来源的会话观察返回完整基线;端点接受任意会话 id,不依赖 catalog 专用校验。观察同时提供数值和序列游标;初始基线与实时控制帧遵循已有序列排序,旧响应不能覆盖新值。端点不激活 Agent 或采样子活动。打开对话使用 follow 基线,不另行请求投影。未打开分支使用会话列表的头部关系,不请求子投影;缺失的历史 catalog 条目保持为非持久化、未解析的导航行,直到打开子会话。
-功能消费者从 `projectionsBySession` 中选择 `subagentCatalog`,并从 Session 列表基线与状态事件派生行活动状态。Session 摘要单独报告 Agent 可用性;Agent 创建与销毁重新发布已有摘要事件。完成的子代理在移除后保留摘要与展示 projection。通用 projection 读取拥有加载与错误状态;父 Agent 可用性来自 Session 列表,独立于成员关系。打开已加载目录会复用数据,初始失败可以重试。重连取消此前的读取,并为已请求的目录重新读取。Session 移除会取消其待完成读取并将父 Agent 可用性标为 false;projection 响应不能改变 Agent 可用性。
+功能消费者从 `projectionsBySession` 选择 `subagentCatalog`,用关联父会话的子代理摘要补充缺失行,并从会话列表基线和状态事件派生活动。头部发现的条目携带未解析模式,不授予续接权限。菜单与分支刷新只更新轻量会话列表。打开选中的子会话时校验 descriptor 和父归属并解析模式,失败仅影响该子会话。已有投影排序、通用显式投影读取和独立父可用性保持不变。
[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明持久创建事实与顺序。本决策取代 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 中的专用成员刷新机制;该记录继续保留导航、控制与展示决策。
@@ -29,3 +29,5 @@ Host 摘要替换运行状态与可用性;本地 create/fork 占位摘要只
成员变化使用既有 control stream,无需额外目录通知或读取。对 D 个子级发布完整 projection 值仍需 O(D);本变更不引入增量传输。初始读取返回 observation 的完整 projection 基线,其中包括目录以外的值,以额外初始载荷换取标准基线语义的复用。Session 序号排序不确立复用 Session id 之间的身份关系。
Manager 测试覆盖成员推送、过时初始响应、按需加载、状态组合、完成元数据保留、重试、空目录移除、晚到的 create/fork 响应与重连取消。Host 测试覆盖共享 control stream 与冷 observation。持久化子代理 Web 场景通过发布应用验证嵌套历史导航与续传。
+
+[会话独立迁移决策](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts
index d8fccb3271..0874c1da15 100644
--- a/apps/web/tests/preset-migration.snapshot.ts
+++ b/apps/web/tests/preset-migration.snapshot.ts
@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller'
import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot'
import { launchWebScaffold, webSnapshotMode } from './scaffold.ts'
+const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url))
const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => {
@@ -32,6 +33,27 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
await mkdir(dirname(predecessor), { recursive: true })
await writeFile(predecessor, source)
+ const childId = SessionId('preset-migration-child')
+ const brokenId = SessionId('preset-migration-broken-child')
+ const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n')
+ .map((line): Record => JSON.parse(line) as Record)
+ const childRows: Record[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 }))
+ const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd }
+ const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n'))))
+ const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd')
+ const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd')
+ await mkdir(dirname(childPath), { recursive: true })
+ await mkdir(dirname(brokenPath), { recursive: true })
+ await writeFile(childPath, childSource)
+ await writeFile(brokenPath, Buffer.concat([
+ zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')),
+ zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
+ data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')),
+ ]))
+ const brokenSource = await readFile(brokenPath)
+ const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal)
+ expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId]))
+
const reader = await scaffold.ctx.sessionPersistence.open(id, 'read')
try {
expect(reader.header.agentPreset).toBe('ptc')
@@ -49,6 +71,30 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
.filter(event => event.type === 'agent-preset/selected')
.map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : [])
+ const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd')
+ await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
+ const signal = new AbortController().signal
+ const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'unresolved' } as const
+ const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal)
+ expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type']))
+ await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId }, throughSeq: 1 }, signal))
+ .rejects.toThrow()
+ await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal))
+ .resolves.toMatchObject({ hasMore: false })
+ const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write')
+ await childWriter.close()
+ const childPublished = await readFile(childSuccessor)
+ const childText = Buffer.concat(scanZstdFrames(childPublished).frames
+ .map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8')
+ expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([
+ { ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows,
+ ])
+ expect(await readFile(childPath)).toEqual(childSource)
+ expect(await readFile(brokenPath)).toEqual(brokenSource)
+ await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd')))
+ .rejects.toMatchObject({ code: 'ENOENT' })
+
+ await scaffold.ctx.sessions.flush(resolved.agent.session)
const publishedBytes = await readFile(successor)
const published = Buffer.concat(scanZstdFrames(publishedBytes).frames
.map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8')
@@ -60,6 +106,9 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
: row),
// Agent activation closes its restored prefix with a fresh seed marker.
{ type: 'session/end-seed', seq: rows.length, time: 0, data: {} },
+ { type: 'permission/preset', seq: rows.length + 1, time: 0, data: { preset: 'workspace-write' } },
+ { type: 'sandbox/mode', seq: rows.length + 2, time: 0, data: { mode: 'workspace-write' } },
+ { type: 'approval/policy', seq: rows.length + 3, time: 0, data: { policy: 'ask' } },
].map(row => JSON.stringify(row)).join('\n') + '\n'
const context = { sessionIds: [id], cwd: scaffold.workspaceCwd }
expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context))
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index a3e2cdf686..da1b1dd793 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 9f9a47ff24f67dfda288c046b482ec8a1127787c
-config-catalog.zh.md: 95d9bb8415f5f7bb116f58bbd8f9a46c3cf42182
+config-catalog.md: 8d0f32cc4d1487dd7170737ee12b5c2a3ed70bc6
+config-catalog.zh.md: 677c025fff4486da212a8f1b53d5d8390e410a32
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 9f9a47ff24..8d0f32cc4d 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2208,7 +2208,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 95d9bb8415..677c025fff 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2210,7 +2210,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml
index 034d835426..ae1d614ac8 100644
--- a/docs/subsystems/subagent.i18n.yaml
+++ b/docs/subsystems/subagent.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: d642fe1658926b17cb29f280803f12e61228be60
-subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2
+subagent.md: dc3ebd74d2f6674f3f81c1a77e36cceeaf0664ab
+subagent.zh.md: 03ada2686dd9a55f0ebaa3dc7e8cf5c1ac9c1db1
diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md
index d642fe1658..dc3ebd74d2 100644
--- a/docs/subsystems/subagent.md
+++ b/docs/subsystems/subagent.md
@@ -268,7 +268,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn
The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed.
-`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` retains corpus traversal, child-identity diagnostics, and complete `hasChildren` computation. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
+`SubagentRuntime.listChildren(parentSessionId, signal?)` owns one parent observation and supplements its catalog with Session-header discovery. It returns `SubagentDiscoveryEntry[]`: catalog facts in event order, followed by `{ id: SessionId; createdAt: number; mode: 'unresolved' }` rows for children without a catalog fact. It never opens child bodies. Query failures propagate; a missing parent catalog projection fails explicitly. `listDescendants()` explicitly reads child identities and returns diagnostics and complete `hasChildren` information. [Session-local migration](../../.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md) owns lazy discovery and migration isolation.
`SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity:
@@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct
- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap.
- **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
-`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics.
+`SubagentCatalogEntry` describes a durable successful-creation fact; `SubagentCatalogState` is its host projection state. `SubagentDiscoveryEntry` adds non-durable unresolved header rows for direct listing. Browser consumers combine catalog facts with parent-linked summaries and cached child identities. `SubagentAddress` carries parent and child ids with known or unresolved mode; history opening validates the child descriptor before continuation. `SubagentCatalogRow` belongs to complete-descendant listing.
@@ -574,15 +574,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise
/**
- * Read the parent's durable direct-child catalog without loading or resuming an Agent.
+ * Read the parent's catalog and discover missing direct children from Session headers.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
- * @returns catalog children in parent event order.
+ * @returns catalog children in event order, then unresolved children whose bodies remain unread.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
-listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise
+listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise
/**
* Enumerate the root's complete session-backed subagent tree in stable
diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md
index ff153c7861..03ada2686d 100644
--- a/docs/subsystems/subagent.zh.md
+++ b/docs/subsystems/subagent.zh.md
@@ -268,7 +268,7 @@ interface ContinuableCreateSpec {
模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。
-`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
+`SubagentRuntime.listChildren(parentSessionId, signal?)` 持有一次父观察,并以会话头部发现补充 catalog。它返回 `SubagentDiscoveryEntry[]`:先按事件顺序返回 catalog 事实,再为缺少 catalog 事实的子会话返回 `{ id: SessionId; createdAt: number; mode: 'unresolved' }` 行。不打开任何子正文。查询失败向上传播,缺少父 catalog 投影时显式报错。`listDescendants()` 显式读取子身份,返回诊断和完整 `hasChildren` 信息。[会话独立迁移](../../.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md) 负责按需发现与迁移隔离。
`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
@@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
- **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
-`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。
+`SubagentCatalogEntry` 描述持久化成功创建事实,`SubagentCatalogState` 是其 Host 投影状态。`SubagentDiscoveryEntry` 为直接列表增加非持久化、未解析的头部行。浏览器消费者合并 catalog 事实、关联父会话的摘要与缓存子身份。`SubagentAddress` 携带父子 id 及已知或未解析模式;历史打开在续接前校验子会话 descriptor。`SubagentCatalogRow` 属于完整后代列表。
@@ -578,15 +578,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise
/**
- * Read the parent's durable direct-child catalog without loading or resuming an Agent.
+ * Read the parent's catalog and discover missing direct children from Session headers.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
- * @returns catalog children in parent event order.
+ * @returns catalog children in event order, then unresolved children whose bodies remain unread.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
-listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise
+listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise
/**
* Enumerate the root's complete session-backed subagent tree in stable
diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml
index 04a2230cee..dff680a206 100644
--- a/docs/tool-catalog.i18n.yaml
+++ b/docs/tool-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: 2f5746fc63e73b1caf2692a05ff942c13399e6bc
-tool-catalog.zh.md: 6fb80b689ca569834e8f585c765547d23cec50df
+tool-catalog.md: 03e2813a528ed1415f9e89593ce4dc8fa884b7e5
+tool-catalog.zh.md: d66c7677a0452fac8557596cd2c666136c0630b1
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 2f5746fc63..03e2813a52 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -1886,7 +1886,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
-List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
+List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
```json
{
diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md
index 6fb80b689c..d66c7677a0 100644
--- a/docs/tool-catalog.zh.md
+++ b/docs/tool-catalog.zh.md
@@ -1892,7 +1892,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
-按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
+按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法在未打开时确定身份的子级会作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
```json
{
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 8ceb4a8da4..c2406e4699 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 228ed88ed20b9f9581496468e47079afd1542e4f
-README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
+README.md: 01701383352458de80d82bbfa1183136fb0bdade
+README.zh.md: 4ac801506db13faceee1f8c7971f90a6aae0bfff
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 228ed88ed2..0170138335 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -29,7 +29,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
-Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
+Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. Header-discovered subagent history accepts `mode: 'unresolved'`; opening still validates the exact parent and the child’s own descriptor. The Client derives the usable mode from the opened child projection, and a failed child open does not invalidate its parent.
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index edebf8bdda..4ac801506d 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -29,7 +29,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
-每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
+每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 从头部发现的子代理历史接受 `mode: 'unresolved'`;打开时仍校验精确父会话和子会话自身 descriptor。Client 从已打开子会话的投影确定可用模式,子会话打开失败不会使父会话失效。
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts
index b6061ce2b4..f396b364fd 100644
--- a/packages/api/session-controller/src/client/sessions/manager.ts
+++ b/packages/api/session-controller/src/client/sessions/manager.ts
@@ -165,6 +165,14 @@ export class SessionManager {
}
}
}
+ const summary = this.summaries.find(item => item.sessionId === sessionId)
+ if (summary?.origin === 'subagent' && summary.parentSessionId !== undefined) {
+ return {
+ parentSessionId: summary.parentSessionId,
+ childSessionId: sessionId,
+ mode: this.projectionStores.get(sessionId)?.values().subagent?.mode ?? 'unresolved',
+ }
+ }
return undefined
}
@@ -666,10 +674,6 @@ export class SessionManager {
this.listInflight = null
void this.refreshList()
const parents = new Set(this.projectionLoads.keys())
- for (const id of this.sessions.keys()) {
- const address = this.addresses.get(id)
- if (address !== undefined) parents.add(address.parentSessionId)
- }
for (const { controller } of this.projectionInflight.values()) controller.abort()
this.projectionInflight.clear()
this.projectionLoads.clear()
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index cd38a26fcf..bb4540f94c 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -812,6 +812,7 @@ export class Session implements SessionFace {
}
private buildSnapshot(): SessionSnapshot {
+ const identity = this.projections.values().subagent
return {
sessionId: this.sessionId,
pendingSubmissions: this.pendingSubmissions,
@@ -819,7 +820,9 @@ export class Session implements SessionFace {
subagent: this.address === undefined
? null
: {
- address: this.address,
+ address: this.address.mode === 'unresolved' && identity != null
+ ? { ...this.address, mode: identity.mode }
+ : this.address,
...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
},
removed: this.removed,
diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts
index a22badc1f1..ab31da6737 100644
--- a/packages/api/session-controller/src/history.ts
+++ b/packages/api/session-controller/src/history.ts
@@ -364,7 +364,7 @@ function validateAddress(
reason: 'unsupported',
})
}
- if (identity.mode !== address.mode) {
+ if (address.mode !== 'unresolved' && identity.mode !== address.mode) {
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index a925e9da83..bd4d3d1840 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -393,7 +393,7 @@ export type SessionAddress =
readonly kind: 'subagent'
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
- readonly mode: 'one-shot' | 'continuable'
+ readonly mode: 'one-shot' | 'continuable' | 'unresolved'
}
/** One non-activating Session projection read. */
diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts
index 7ee5be5397..05495adfee 100644
--- a/packages/api/session-controller/tests/manager.client.spec.ts
+++ b/packages/api/session-controller/tests/manager.client.spec.ts
@@ -417,6 +417,33 @@ describe('Host Remote event routing', () => {
})
describe('subagent catalogs', () => {
+ it.for(['one-shot', 'continuable'] as const)('opens a header-discovered child on demand and resolves %s mode', async (mode, { mock, remote, start }) => {
+ remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
+ summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }),
+ ] })))
+ mock.load(sessionWorld)
+ mock.stream(FOLLOW, followScript(ok({
+ records: [], hasMore: false,
+ projections: { asOfSeq: 0, values: { subagent: { mode, label: 'restored', seq: 0 } } },
+ })))
+ const client = await start()
+ const manager = new SessionManager(client.ctx.remote)
+ try {
+ await manager.refreshList()
+ manager.resolveTarget(S2)
+ expect(manager.subagentAddress(S2)).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unresolved' })
+ expect(remote.session.projections).not.toHaveBeenCalled()
+ expect(mock.log.requests(FOLLOW)).toEqual([])
+ const child = manager.get(S2)
+ await child.open()
+ expect(mock.log.requests(FOLLOW)).toEqual([{
+ address: { kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'unresolved' },
+ assistantStream: true, maxMessages: 50,
+ }])
+ expect(child.getSnapshot().subagent?.address.mode).toBe(mode)
+ } finally { await manager.dispose() }
+ })
+
it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
@@ -1010,33 +1037,24 @@ describe('connected generation', () => {
expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
})
- it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => {
+ it('retains the durable parent address across reconnect without opening the parent', async ({ mock, remote }) => {
const address = {
parentSessionId: S1,
childSessionId: S2,
mode: 'continuable' as const,
}
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [summary(S1)] as never[] })))
- const parent = Promise.withResolvers>>()
- remote.session.projections.mockImplementation(() => parent.promise)
const manager = makeManager(mock, remote)
manager.resolveTarget(address)
manager.get(S2)
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
- parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
- id: S2, mode: 'continuable', label: 'worker',
- }] } }))
await vi.waitFor(() => {
expect(remote.session.list.mock.calls.map(([request]) => request)).toHaveLength(1)
})
- await vi.waitFor(() => {
- expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([
- { sessionId: S1 },
- ])
- })
+ expect(remote.session.projections).not.toHaveBeenCalled()
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
parentAvailable: true,
diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts
index 04b43972e9..2fae510838 100644
--- a/packages/api/session-controller/tests/transport.host.spec.ts
+++ b/packages/api/session-controller/tests/transport.host.spec.ts
@@ -641,6 +641,27 @@ describe('SessionHistoryController', () => {
.rejects.toMatchObject({ code: 'subagent/unauthorized' })
})
+ it('resolves a header-discovered address while retaining child ownership and descriptor validation', async () => {
+ const bench = await setup()
+ const parentSessionId = SessionId('unresolved-parent')
+ const childSessionId = SessionId('unresolved-child')
+ const header: SessionHeader = {
+ version: SESSION_FORMAT_VERSION, id: childSessionId, createdAt: 1, cwd: '/workspace',
+ isSeeded: false, origin: 'subagent', parentSession: parentSessionId,
+ }
+ cold(bench.ctx, header, [event('subagent/descriptor', SessionSeq(0), {
+ version: 3, mode: 'one-shot', provider: 'spawn',
+ })])
+ const address = { kind: 'subagent', parentSessionId, childSessionId, mode: 'unresolved' } as const
+ await expect(bench.transport.page({ address, throughSeq: 0 }, signal())).resolves.toMatchObject({
+ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
+ })
+ await expect(bench.transport.page({ address: { ...address, parentSessionId: SessionId('wrong') }, throughSeq: 0 }, signal()))
+ .rejects.toMatchObject({ code: 'subagent/unauthorized' })
+ await expect(bench.transport.page({ address: { ...address, mode: 'continuable' }, throughSeq: 0 }, signal()))
+ .rejects.toMatchObject({ code: 'subagent/unauthorized' })
+ })
+
it('reports an unavailable descriptor when an observed child has no projection value', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml
index 45a496719c..83ca816b51 100644
--- a/packages/client/ui-subagent/README.i18n.yaml
+++ b/packages/client/ui-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/client/ui-subagent/README.md
-README.md: 1404e893add386b88a24d78675a4f0be06f497a0
-README.zh.md: 97c743b580e25cd50bcb01a3a7547cb7415a0e08
+README.md: 33d8da78b2cf27ccce1cfa8ee7c26ddb43b3d083
+README.zh.md: 9cc1fff725d8539abc648ab7d198413194674160
diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md
index 1404e893ad..33d8da78b2 100644
--- a/packages/client/ui-subagent/README.md
+++ b/packages/client/ui-subagent/README.md
@@ -25,17 +25,17 @@ Use this package to browse every subagent conversation beneath a parent session,
## Use this package
-The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
+The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger for known direct children or a catalog error. Rows combine existing parent catalog facts with Session-list header relationships, so historical children remain discoverable without opening their logs. Opening the menu or expanding a branch refreshes the lightweight Session list. Selecting a row opens only that child through its exact `{parentSessionId, childSessionId, mode}` address; a header-only row uses `mode: 'unresolved'` until its own descriptor is read. The trailing arrow opens the same address in the right Sidebar.
-This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
+This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the lightweight Session list before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
### Browsing the tree
-Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
+Rows display mode plus activity and an optional log-backed title; unresolved rows display “Not opened” and use their id until identity data is available. Known parent catalog labels remain authoritative. Running, completed, and other inactive children use the shared ongoing, success, and idle indicators. Cached usage and duration remain optional. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree. A header-discovered child with no listed descendants or cached catalog entries is a leaf without a body read.
### Continuing a conversation
-A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable.
+An unresolved child keeps a read-only composer until opening its history supplies a valid identity. A continuable child with a live parent keeps the ordinary input chrome: follow-ups join the child’s FIFO inbox, and Stop routes through `subagents/interruptByParent`. A continuable child whose parent is unavailable and which is not running uses a read-only composer; while it runs, input and Send remain disabled but Stop stays usable. One-shot history remains read-only.
### The `@` reference source
diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md
index 97c743b580..9cc1fff725 100644
--- a/packages/client/ui-subagent/README.zh.md
+++ b/packages/client/ui-subagent/README.zh.md
@@ -25,17 +25,17 @@ kind: "package-reference"
## 使用本包
-会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
+会话头部保留当前标题作为层级导航,并为已知直接子会话或 catalog 错误追加 `/` 数量入口。列表行合并已有父 catalog 事实和会话列表中的头部关系,让历史子会话无需打开日志即可被发现。打开菜单或展开分支只刷新轻量会话列表。选择一行时,通过精确的 `{parentSessionId, childSessionId, mode}` 地址只打开该子会话;只有头部的条目使用 `mode: 'unresolved'`,直到读取自身 descriptor。末尾箭头将同一地址打开到右侧 Sidebar。
-本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
+本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源和内置 Sidebar 标签类型。资源在持有子会话 `SessionReference` 前刷新轻量会话列表,并在标签记录关闭时释放引用。标签通过 `sidebar.chat.conversation` 渲染共享的 `conversation.content` Factory,将局部 View 固定为 Chat,省略主 Conversation 头部和宽度控件。
### 浏览目录
-行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
+列表行显示模式、活动状态和可选的日志标题;未解析条目显示“尚未打开”,在身份数据可用前使用 id。已有父 catalog 标签仍具有权威性。运行、完成和其他空闲子会话分别使用共享的进行中、成功和空闲标记。缓存的用量和耗时为可选项。ArrowRight/ArrowLeft 展开或收起分支,ArrowUp/ArrowDown、Home、End、Escape 用于导航或关闭树。头部发现的子会话若没有列出的后代或缓存 catalog 条目,无需读取正文即可作为叶节点。
### 续接对话
-确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。
+未解析子会话保持只读输入区,直到打开历史记录并得到有效身份。父代理在线的可继续子会话使用普通输入区:后续消息进入子代理 FIFO 收件箱,Stop 经 `subagents/interruptByParent` 路由。父代理不可用且未运行的可继续子会话使用只读输入区;仍运行时禁用输入和 Send,但保留 Stop。一次性子会话历史始终只读。
### `@` 引用 source
diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
index 6dd416fb95..e734fd9dd9 100644
--- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
+++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
@@ -17,7 +17,7 @@ import css from './SubagentHeaderLineage.module.css'
type SubagentCatalogSnapshot = Omit & {
state: 'loading' | 'ready' | 'error'
- entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[]
+ entries: { id: SessionId; mode: SubagentAddress['mode']; label?: string; activity: 'running' | 'inactive' }[]
}
type Catalogs = Readonly>
@@ -238,7 +238,8 @@ function CatalogRows({
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
- const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
+ const mode = entry.mode === 'unresolved' ? t('mode.unresolved')
+ : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
const completed = entry.activity === 'inactive'
&& summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true
const activity = entry.activity === 'running'
@@ -451,15 +452,31 @@ function CatalogDropdown({
const projections = useSessions(state => state.projectionsBySession)
const summaries = useSessions(state => state.byId)
const statuses = useSessionStatus(value => value)
- const catalogs = useMemo(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
- state: snapshot.state === 'idle'
- ? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
- : snapshot.state,
- error: snapshot.error,
- entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
- ...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
- })),
- }])), [projections, summaries, statuses])
+ const catalogs = useMemo(() => {
+ const result: Record = Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
+ state: snapshot.state === 'idle'
+ ? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
+ : snapshot.state,
+ error: snapshot.error,
+ entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
+ ...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
+ })),
+ }]))
+ const known = new Map(Object.entries(result).map(([id, catalog]) => [id, new Set(catalog.entries.map(entry => entry.id))]))
+ for (const summary of Object.values(summaries)) {
+ if (summary.origin !== 'subagent' || summary.parentId === undefined) continue
+ result[summary.id] ??= { state: 'ready', error: null, entries: [] }
+ const catalog = result[summary.parentId] ??= { state: 'ready', error: null, entries: [] }
+ if (known.get(summary.parentId)?.has(summary.id)) continue
+ const identity = summary.projectionValues?.subagent
+ catalog.entries.push({
+ id: summary.id, mode: identity?.mode ?? 'unresolved',
+ ...(identity?.label === undefined ? {} : { label: identity.label }),
+ activity: (statuses.get(summary.id)?.running ?? summary.running) ? 'running' : 'inactive',
+ })
+ }
+ return result
+ }, [projections, summaries, statuses])
const catalog = catalogs[rootSessionId]
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState()
@@ -473,7 +490,7 @@ function CatalogDropdown({
? undefined
: catalog?.entries.find(entry => entry.id === currentSessionId)
const switcherDisplayTitle = currentEntry !== undefined
- ? currentEntry.label ?? currentEntry.id
+ ? currentEntry.label ?? (currentEntry.mode === 'unresolved' ? displayTitle : currentEntry.id)
: displayTitle
const directChildren = catalog?.entries ?? []
const directCount = directChildren.length
diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx
index 158e8cb77b..1271d13f2c 100644
--- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx
+++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx
@@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css'
/** Why a catalog-addressed conversation cannot accept human input. */
export interface SubagentReadOnlyMatch {
- reason: 'one-shot' | 'parent-unavailable'
+ reason: 'one-shot' | 'parent-unavailable' | 'unresolved'
}
/** Full chain props after the read-only subagent selector accepts the owner currency. */
@@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({