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

Merge origin/master into codex/subprocess-native-containment

pku-xht пре 1 месец
родитељ
комит
aa4d80c0a9
100 измењених фајлова са 1134 додато и 341 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  2. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  3. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  5. 0 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml
  8. 2 1
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
  9. 2 1
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
  11. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
  12. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml
  14. 70 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
  15. 70 0
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml
  17. 33 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md
  18. 33 0
      .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md
  19. 6 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml
  20. 75 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md
  21. 75 0
      .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md
  22. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml
  23. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
  24. 2 2
      .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md
  25. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml
  26. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
  27. 1 1
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md
  28. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.i18n.yaml
  29. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md
  30. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md
  31. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml
  32. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md
  33. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md
  34. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml
  35. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md
  36. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md
  37. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  38. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  39. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  40. 2 2
      .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml
  41. 3 1
      .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md
  42. 3 1
      .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md
  43. 6 7
      .github/workflows/build-exe-for-python-sdk.yml
  44. 1 1
      .github/workflows/ci.yml
  45. 53 10
      apps/web/tests/agent-preset-selection.e2e.ts
  46. 1 0
      apps/web/tests/expected/agent-preset-selection/menu.expected.md
  47. 7 7
      apps/web/tests/permission-policy-context.e2e.ts
  48. 15 0
      apps/web/tests/question-composer.e2e.ts
  49. 3 3
      apps/web/tests/subagent-conversation.e2e.ts
  50. 6 6
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  51. 32 34
      apps/web/tests/subagent-interrupt.e2e.ts
  52. 10 0
      apps/web/tests/support.ts
  53. 26 6
      apps/web/tests/trajectory-image-display.expected.e2e.ts
  54. 2 2
      docs/config-catalog.i18n.yaml
  55. 2 2
      docs/config-catalog.md
  56. 2 2
      docs/config-catalog.zh.md
  57. 2 2
      docs/event-producer-consumer.i18n.yaml
  58. 4 4
      docs/event-producer-consumer.md
  59. 4 4
      docs/event-producer-consumer.zh.md
  60. 2 2
      docs/module-graph.i18n.yaml
  61. 6 3
      docs/module-graph.md
  62. 6 3
      docs/module-graph.zh.md
  63. 2 2
      docs/subsystems/client-modules.i18n.yaml
  64. 2 6
      docs/subsystems/client-modules.md
  65. 2 6
      docs/subsystems/client-modules.zh.md
  66. 2 2
      docs/subsystems/persistence.i18n.yaml
  67. 1 1
      docs/subsystems/persistence.md
  68. 1 1
      docs/subsystems/persistence.zh.md
  69. 2 2
      docs/subsystems/slots.i18n.yaml
  70. 2 0
      docs/subsystems/slots.md
  71. 2 0
      docs/subsystems/slots.zh.md
  72. 2 2
      docs/subsystems/subagent.i18n.yaml
  73. 47 0
      docs/subsystems/subagent.md
  74. 47 0
      docs/subsystems/subagent.zh.md
  75. 2 2
      package.json
  76. 2 0
      packages/api/remotes/package.json
  77. 5 2
      packages/api/remotes/src/client/index.ts
  78. 3 0
      packages/api/remotes/tsconfig.client.json
  79. 4 3
      packages/api/session-controller/src/client/contract/result.ts
  80. 1 1
      packages/api/session-controller/src/client/contract/sessions.ts
  81. 1 1
      packages/api/session-controller/src/client/contract/snapshot.ts
  82. 2 1
      packages/api/session-controller/src/client/index.ts
  83. 4 7
      packages/api/session-controller/src/client/sessions/manager.ts
  84. 18 0
      packages/api/session-controller/src/client/sessions/remotes.ts
  85. 1 6
      packages/api/session-controller/src/client/sessions/service.ts
  86. 13 10
      packages/api/session-controller/src/client/sessions/session.ts
  87. 1 0
      packages/api/session-controller/tests/client-apply.client.spec.ts
  88. 33 15
      packages/api/session-controller/tests/fake-api.client.ts
  89. 73 77
      packages/api/session-controller/tests/manager.client.spec.ts
  90. 7 7
      packages/api/session-controller/tests/projection-store.client.spec.ts
  91. 3 3
      packages/api/session-controller/tests/queue-store.client.spec.ts
  92. 15 13
      packages/api/session-controller/tests/session.client.spec.ts
  93. 10 9
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  94. 1 0
      packages/api/session-controller/tsconfig.client.json
  95. 0 1
      packages/client/connection/src/client/api.ts
  96. 11 10
      packages/client/connection/src/client/fixture.ts
  97. 0 1
      packages/client/connection/src/client/index.ts
  98. 0 13
      packages/client/connection/tests/fake-api.client.ts
  99. 2 2
      packages/client/hmr/README.i18n.yaml
  100. 1 1
      packages/client/hmr/README.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 02ff4130b9753ed36b1780dc5b0e9832cd8c4586
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 4db455881f55bed5abde65ad72f9a5a2c6c6a357
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 6fbf4ba0c303f8d39a11dfe91ef3ee77f0f31904
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d027d7e015a831d63402de3bfd02694fa542cb90

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


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

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


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

@@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
 浏览器侧,驱动插件每帧重载一个插件,串行执行:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
-2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81
-2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371
+2026-08-20-webworker-node-face.md: 05c3bfe7fa07b34189229f6454be597d4956226a
+2026-08-20-webworker-node-face.zh.md: 0a0f4badff192f02e962cfb38b64ef9d89fcd420

+ 2 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md

@@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 
 ## Decision
 
-**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly.
+**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. The shim includes `process.title`: packages such as `@xterm/headless` use that property's presence to select their Node path, while omitting it makes a dedicated Worker look like a browser Window and reaches DOM-only globals.
 
 **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning.
 
@@ -31,4 +31,5 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 - `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world.
 - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`.
 - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts.
+- `tests/node/process-shim.spec.ts` pins the Node detection field independently from the test runner's ambient Node process.
 - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites.

+ 2 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md

@@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 
 ## 决定
 
-**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
+**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。Shim 包含 `process.title`:`@xterm/headless` 等包通过该属性是否存在来选择 Node 路径;缺少它会让 dedicated Worker 被误判为浏览器 Window,进而访问仅适用于 DOM 的全局对象。
 
 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。
 
@@ -31,4 +31,5 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 - `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。
 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。
 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。
+- `tests/node/process-shim.spec.ts` 独立于测试运行器自带的 Node process,钉住 Node 环境识别字段。
 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
-2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287
-2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1
+2026-08-20-webworker-pack-lowering-and-preview.md: 24dd5ba6e3eb633253321b219781b537f1faf429
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 5ee59e9a508767be928e2de804514b6357fe4ffd

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

@@ -10,7 +10,7 @@ The browser worker can neither compile modules at load nor be served by the prod
 
 ## Decision
 
-**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
+**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; the same parse feeds reachability with statically named imports, re-exports, and dynamic imports, calls through `require`, and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import. Stored results, CommonJS-obtained `createRequire`, computed request names, and other bases stay runtime-only; targets reachable only through those forms require image entry seeds. Inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there.
 
 **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
 
@@ -37,7 +37,7 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental
 ## Consequences
 
 - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
-- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
+- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, the transform semantic suite pins `createRequire` request discovery, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
 - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not.
 - The served `<base href="/">` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
 - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.

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

@@ -10,7 +10,7 @@
 
 ## 决定
 
-**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
+**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;同一次解析会把具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用送入可达性遍历。保存下来的结果、经 CommonJS 获取的 `createRequire`、计算得到的请求名称与其他基准只在运行时解析;只能通过这些形式触达的目标需要镜像入口种子。worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。
 
 **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
 
@@ -37,7 +37,7 @@
 ## 后果
 
 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
-- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
+- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,transform 语义套件钉住 `createRequire` 请求发现,`apps/web/tests/preview-boot.e2e.ts` 则在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
 - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。
 - served 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
+2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00
+2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea

+ 70 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md

@@ -0,0 +1,70 @@
+# Agent Note: Persistence compression latency and SQLite page size
+
+Status: implemented
+
+English | [中文](2026-08-25-persistence-latency-and-page-size.zh.md)
+
+## Problem
+
+The physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or session forks. The original 105-session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The earlier SQLite page-size experiment predated shared-dictionary row compression and showed negligible savings, so it did not establish the best page size for the current row distribution.
+
+The decision needs evidence from more varied sessions, including long event streams and payloads outside the original corpus. The expanded corpus contains 501 real sessions, 16,153,332 logical events, and 2,002,145,570 bytes of serialized event data.
+
+## Decision
+
+### Storage encoding stays physical and independently decodable
+
+JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
+
+SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
+
+### JSONL uses the standard Zstandard level
+
+The JSONL writer keeps one checksummed Zstandard frame per durable append batch but uses the compressor's standard level. Lossless `sourceEventSeqs` range encoding remains active. Frames stay independently decodable for suffix reads and torn-tail recovery; only the expensive level-19 search is removed.
+
+### New SQLite databases use 64 KiB pages
+
+The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation.
+
+The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
+
+### Expanded benchmark
+
+Each candidate was rebuilt five times from the same 501-session corpus with 512-event append batches. Their order rotates between rounds so every candidate occupies each run position once. Each build runs three complete and suffix-read sweeps. For each displayed metric, the highest and lowest build are discarded and the remaining three values are averaged. Complete and suffix read times cover one sweep over all sessions, and fork time covers all 501 sessions.
+
+| Backend | Stored size | Full write | Full read | Suffix read | Fork |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
+| JSONL with provenance ranges | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
+| JSONL with provenance ranges and level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
+| SQLite `master` (schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
+| SQLite with all physical optimizations and 64 KiB pages | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
+
+Relative to standard-level frames with provenance ranges, level 19 saves another 12.1% of the JSONL bytes but increases full-write time by 67.0% and fork time by 129.8%. Its complete and suffix reads change by -0.4% and -0.5%. The extra search therefore benefits retained size without improving the latency-sensitive operations enough to offset its repeated encoding cost.
+
+An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages use 256.97 MB and 64 KiB pages use 233.18 MB (-9.26%). The `events` table's unused page bytes fall from 30.25 MB to 6.95 MB, while the index changes from 5.92 MB to 6.03 MB. In the paired run, full write, full read, and suffix read change by -0.5%, -0.4%, and -3.8%; fork changes by -14.8%. The space gain therefore comes from better large-row page utilization rather than a smaller index or omitted data, without a measured latency regression.
+
+## Alternatives considered
+
+**Keep JSONL level 19.** Rejected. On the expanded corpus it saves another 12.1% relative to default-level frames but increases full-write time by 67.0% and fork time by 129.8%, while complete and suffix reads differ by less than 1%. Default-level frames plus provenance ranges retain a 14.1% size reduction relative to master without a material latency regression.
+
+**Compress one whole JSONL log as a single frame.** Rejected. It improves cross-batch compression but makes suffix reads decompress from the start and removes batch-local torn-tail recovery.
+
+**Keep 4 KiB SQLite pages.** Rejected for pristine databases. The current compressed-row distribution retains 9.26% more bytes because large compressed records leave more unusable space across 4 KiB B-tree pages. Existing databases keep their page size to avoid a historical rewrite.
+
+**Remove ROWID from `events`.** Rejected. The composite primary key becomes the table B-tree key and repeats through internal pages; the 105-session comparison produced a larger database than ordinary ROWID tables.
+
+**Deduplicate event content.** Rejected. Message restatements and tool arguments can be reconstructed only under assumptions that compaction, retries, and pruning may invalidate. Physical compression preserves every event without adding reconstruction semantics.
+
+**Use per-session SQLite files or DuckDB.** Rejected for the hot store. Per-session files lose cross-session queries, while DuckDB's OLAP write model fits cold batch analysis rather than durable append batches and low-latency suffix reads.
+
+## Consequences
+
+JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
+
+New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it.
+
+## Related
+
+- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.md) — owns the packed row model; its earlier page-size conclusion applies to the pre-dictionary layout.
+- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.md) — owns the checksummed frame-per-batch container and the standard compressor-level policy restored here.

+ 70 - 0
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md

@@ -0,0 +1,70 @@
+# Agent Note: 持久化压缩延迟与 SQLite page size
+
+Status: implemented
+
+[English](2026-08-25-persistence-latency-and-page-size.md) | 中文
+
+## 问题
+
+物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或会话 fork。原有的 105 会话语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。此前的 SQLite page-size 实验早于共享字典行压缩,所得空间收益可以忽略,因此无法确定当前行分布的最佳 page size。
+
+该决策需要来自更多样会话的证据,包括长事件流与原语料之外的 payload。扩展后的语料包含 501 个真实会话、16,153,332 个逻辑事件与 2,002,145,570 字节序列化事件数据。
+
+## 决策
+
+### 存储编码保持为物理层行为并可独立解码
+
+JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
+
+SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
+
+### JSONL 使用 Zstandard 标准级别
+
+JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Zstandard frame,但使用压缩器的标准级别。无损 `sourceEventSeqs` 区间编码继续生效。各 frame 仍可独立解码,以支持后缀读取与撕裂尾部恢复;只移除昂贵的 level-19 搜索。
+
+### 新建 SQLite 数据库使用 64 KiB page
+
+SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。
+
+Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
+
+### 扩展基准
+
+每个候选方案都从同一份 501 会话语料独立重建五次,每个 append 批次包含 512 个事件。各轮轮换执行顺序,使每个候选方案在每个运行位置各出现一次。每次重建执行三轮完整读取与后缀读取。下表中的每项指标都去掉最高与最低的一次重建,再平均其余三次。完整读取与后缀读取耗时覆盖对全部会话的一轮扫描,fork 耗时覆盖全部 501 个会话。
+
+| 后端 | 存储大小 | 完整写入 | 完整读取 | 后缀读取 | Fork |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s |
+| JSONL + 来源区间 | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) |
+| JSONL + 来源区间 + level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) |
+| SQLite `master`(schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s |
+| SQLite + 全部物理优化 + 64 KiB page | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) |
+
+相对使用来源区间的标准级别 frame,level 19 可再减少 12.1% 的 JSONL 字节,但会让完整写入增加 67.0%、fork 增加 129.8%;完整读取与后缀读取分别变化 -0.4% 与 -0.5%。因此,更深入的搜索只改善保留体积,无法通过延迟敏感操作的收益抵消反复付出的编码成本。
+
+其余条件相同的 SQLite 重建可单独观察 page-size 影响:4 KiB page 使用 256.97 MB,64 KiB page 使用 233.18 MB(-9.26%)。`events` 表的 page 内未使用字节从 30.25 MB 降至 6.95 MB,索引则从 5.92 MB 变为 6.03 MB。在该成对运行中,完整写入、完整读取与后缀读取分别变化 -0.5%、-0.4% 与 -3.8%,fork 变化 -14.8%。因此,空间收益来自更高的大记录 page 利用率,而不是索引缩小或数据省略,并且没有测得延迟退化。
+
+## 考虑过的替代方案
+
+**保留 JSONL level 19。** 不予采用。在扩展语料上,它相对默认级别 frame 可再减少 12.1%,却让完整写入增加 67.0%、fork 增加 129.8%,而完整读取与后缀读取的差异都不足 1%。默认级别 frame 配合来源区间后,相对 master 仍能缩小 14.1%,且没有实质性延迟退化。
+
+**把整份 JSONL 日志压成单个 frame。** 不予采用。该方案可改善跨批次压缩,但后缀读取必须从头解压,也会失去按批次恢复撕裂尾部的能力。
+
+**新建 SQLite 数据库继续使用 4 KiB page。** 不予采用。当前压缩行分布会在 4 KiB B-tree page 之间留下更多不可用空间,使保留字节增加 9.26%。已有数据库保留其 page size,避免改写历史数据。
+
+**从 `events` 移除 ROWID。** 不予采用。复合主键会成为表 B-tree 键并在内部 page 中重复;105 会话对比所得数据库大于使用普通 ROWID 的表。
+
+**对事件内容去重。** 不予采用。消息复述与工具参数只能在依赖重建假设时删除,而 compaction、重试和修剪可能让这些假设失效。物理压缩保留每个事件,不增加重建语义。
+
+**使用逐会话 SQLite 文件或 DuckDB。** 不用于热存储。逐会话文件会失去跨会话查询,DuckDB 的 OLAP 写入模型则更适合冷批量分析,而不是持久 append 批次与低延迟后缀读取。
+
+## 后果
+
+JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
+
+新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。
+
+## 相关资料
+
+- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) — 定义打包行模型;其此前的 page-size 结论适用于共享字典之前的布局。
+- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.zh.md) — 定义带 checksum 的按批次 frame 容器,以及本笔记恢复的标准压缩级别策略。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md
+2026-08-26-models-page-extension-slots.md: 9983c669b7838de00d49a634a54e5d115ef4945a
+2026-08-26-models-page-extension-slots.zh.md: 639654ea4b912d43a95f0a9295969523f4e1c5f8

+ 33 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.md

@@ -0,0 +1,33 @@
+# Agent Note: Models-page extension slots
+
+Status: implemented
+
+English | [中文](2026-08-26-models-page-extension-slots.zh.md)
+
+## Problem
+
+Provider sign-in for the pi-ai catalog (GitHub Copilot, OpenAI accounts) is moving out of the product into an optional out-of-tree plugin for provider terms-of-service reasons. The plugin needs its sign-in button and attempt UI inside the Models page's provider cards — the surface where a user meets a provider — but `ui-settings-models` rendered its cards from closed code: the only integration path was editing this package, which an external plugin cannot do, and the page's one open seam (`settings.section`) can only add a whole separate page.
+
+## Decision
+
+`ui-settings-models` declares two SlotMap seats in `src/client/slot-contract.ts`, claims them as `children` of its `settings.section` registration, and re-exports their types from `./client` so an out-of-tree plugin can merge them with a type-only import.
+
+`settings.models.provider-card` is `keyed` with `entryKey = ConfigurableProviderView.settingsNs`: one registration under an adapter family's settings namespace receives every card of that family — shipped catalog routes, adopted directory rows, and hand-declared routes alike — while the section never interprets the key. The key domain stays the open string space (no `keyProps` table) because hand-declared route ids are user-chosen at runtime. The seat dispatches on every card that shows a directory row: a saved row's card, its first-run setup posture, and the add-provider draft (its dormant row, `configured: false` in practice), which is where sign-in matters most — the user has just met the provider and holds no key. The hand-declared draft card has no directory row before saving and dispatches nothing. Owner props carry the row's `ConfigurableProviderView`, its `configured` join, and its confirmed api-key credential state (`keyConfigured`, which the first consumer uses to withhold sign-in beside a stored key); nothing more has a current consumer.
+
+`settings.models.footer` is a `list` seat after the rows and the add controls, for section-level extension content such as orphaned-record management.
+
+Without registrants both seats render nothing, so the shipped page is pixel-identical to before.
+
+## Alternatives considered
+
+**A `list` seat with self-filtering registrants instead of keying.** Every registrant would render (and return null) on every card, and two plugins could silently interleave UI inside one family's cards. Keying by namespace gives one accountable extension owner per adapter family and zero wasted dispatches, and reuses the exact pairing rationale of `settings.plugin.item`.
+
+**Keying by provider route id.** Route ids are dynamic — hand-declared routes are named by users at runtime — so a plugin could not register ahead of the rows it wants and would have to churn registrations as the directory changes.
+
+**A `chain` seat replacing the whole card body.** No current consumer needs to replace the editor; the sign-in surface is additive. A takeover contract would also make the section's layout a compatibility surface. A chain can still be added later without disturbing these seats.
+
+**Keeping the sign-in UI wired inside `ui-settings-models` (the pre-plugin design).** Ships the terms-of-service-sensitive surface in the product, which is the outcome this extension point exists to avoid.
+
+## Consequences
+
+An out-of-tree plugin can now integrate per-family card UI into the Models page with no product edits; `llm-pi-ai-oauth` is the first consumer. The cost is a public contract: `ProviderCardExtrasOwnerProps` exposes `ConfigurableProviderView` at the `./client` boundary, and the dispatch sites (saved card, setup posture, add draft, footer) become behavior extensions rely on. Per adapter family the keyed cell renders one owner at a time: a second registration under the same namespace at the same priority is refused by the registry, while a different priority deliberately shadows it (the lowest-priority entry renders) — the slot kit's standard override channel, never a silent merge.

+ 33 - 0
.agents/notes/implemented/architecture/2026-08-26-models-page-extension-slots.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Models 页扩展插槽
+
+Status: implemented
+
+[English](2026-08-26-models-page-extension-slots.md) | 中文
+
+## Problem
+
+出于提供方服务条款的考虑,pi-ai catalog 的提供方登录(GitHub Copilot、OpenAI 账号)正从产品中移出,改由一个可选的仓库外插件承担。该插件需要把登录按钮与登录过程 UI 放进 Models 页的提供方卡片——用户与提供方相遇的界面——但 `ui-settings-models` 的卡片由封闭代码渲染:唯一的集成途径是修改本包,外部插件做不到;页面仅有的开放缝隙(`settings.section`)只能新增一整个独立页面。
+
+## Decision
+
+`ui-settings-models` 在 `src/client/slot-contract.ts` 声明两个 SlotMap 席位,在其 `settings.section` 注册中以 `children` 认领它们,并从 `./client` 再导出其类型,使仓库外插件通过 type-only import 即可获得类型合并。
+
+`settings.models.provider-card` 为 `keyed`,`entryKey = ConfigurableProviderView.settingsNs`:以某适配器家族的 settings namespace 注册一次,即可收到该家族的全部卡片——内置 catalog 路由、从目录采纳的行、手工声明的路由一视同仁——而分区从不解释这个 key。键域保持开放字符串空间(不设 `keyProps` 表),因为手工声明的路由 id 由用户在运行时命名。该席位在每张展示目录行的卡片上分发:已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡(其休眠行,实际为 `configured: false`)——草稿卡正是登录价值最大的时刻:用户刚遇到该提供方、手中还没有密钥。手工声明的草稿卡在保存前没有目录行,不分发。Owner props 携带该行的 `ConfigurableProviderView`、其 `configured` 合并结果与已确认的 api-key 凭据状态(`keyConfigured`,首个消费者用它在已存密钥旁抑制登录入口);更多字段没有现役消费者。
+
+`settings.models.footer` 为 `list` 席位,位于行列表与新增控件之后,承载孤儿记录管理这类分区级扩展内容。
+
+没有注册方时两个席位均不渲染,产品页面与之前逐像素一致。
+
+## Alternatives considered
+
+**用 `list` 席位、由注册方自行筛选,替代按键分发。**每个注册方都会在每张卡片上渲染(再返回 null),且两个插件可能在同一家族的卡片里静默交错 UI。按 namespace 分发让每个适配器家族有唯一可问责的扩展所有者、零浪费分发,并完全复用 `settings.plugin.item` 的配对理由。
+
+**按提供方路由 id 分发。**路由 id 是动态的——手工声明的路由由用户在运行时命名——插件无法先于目标行注册,还得随目录变化反复重注册。
+
+**用 `chain` 席位整体接管卡片。**没有现役消费者需要替换编辑器;登录界面是加法。接管契约还会让分区布局成为兼容面。将来仍可在不动这两个席位的前提下追加 chain。
+
+**把登录 UI 继续织在 `ui-settings-models` 里(插件化之前的设计)。**会把服务条款敏感的界面随产品发布,而这正是本扩展点要避免的结果。
+
+## Consequences
+
+仓库外插件现在无需改动产品即可把按家族的卡片 UI 集成进 Models 页;`llm-pi-ai-oauth` 是首个消费者。代价是一份公开契约:`ProviderCardExtrasOwnerProps` 在 `./client` 边界暴露 `ConfigurableProviderView`,且各分发位点(已保存卡片、setup 形态、新增草稿、footer)成为扩展方依赖的行为。每个适配器家族的 keyed 单元格同一时刻只渲染一个所有者:同一 namespace 下同 priority 的第二次注册会被注册表拒绝,不同 priority 则是刻意的遮蔽(最低 priority 的条目渲染)——这是 slot 套件的标准覆盖通道,绝非静默合并。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md
+2026-08-26-preset-health-resolves-rows.md: 909423e373bc2368b5fb1e81b1172275920282aa
+2026-08-26-preset-health-resolves-rows.zh.md: d2cffae66f7bd7fb78e068bd4242d009b95d3c5c

+ 75 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md

@@ -0,0 +1,75 @@
+# Agent Note: Preset health resolves the rows it can prove will start
+
+Status: implemented
+
+English | [中文](2026-08-26-preset-health-resolves-rows.zh.md)
+
+## Problem
+
+A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config.
+
+This note partly supersedes [broken presets are roster rows](../bug-fix/2026-08-09-broken-preset-roster-rows.md), whose rejected "validating deep" alternative is what shipped here, and it moved the reason off the card face; it also relaxes the shipped-roster assertion [plugin-owned shipped preset root](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md) records. Both are updated in place.
+
+`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose".
+
+The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `<dshHome>/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded.
+
+The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row.
+
+## Decision
+
+**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `<package>/package.json`. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way.
+
+The disk lookup, not `import.meta.resolve`, for two reasons. It is the cheap one: a registered ESM loader hook turns every resolver call into a synchronous round-trip to the hooks thread, measured under the `tsx` hook the source launch installs at 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node — 238ms of resolver time per roster read, where the walk answers the same 135 rows in 0.7ms. It is also the only one that can be asked about the harness at all: `import.meta.resolve`'s `parentURL` argument takes effect only under `--experimental-import-meta-resolve`, which no launch passes, so it resolves from the calling module and would answer about this package rather than about the deployment. The resolver that does honour an explicit parent is the Loader's internal one, whose `resolveSync` signature differs between Node 22 and 24. A Node builtin short-circuits ahead of the walk.
+
+What the walk gives up: a package resolvable only through a loader hook — an import map, or a tree with no `node_modules` — is reported broken. No supported install produces one, because `dsh plugin install` puts every plugin beside the roster.
+
+**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load.
+
+**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner.
+
+**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end.
+
+**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them.
+
+**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is revealed by hovering the badge or focusing the card, and a visually hidden `role="alert"` node announces it. A broken card says so through `aria-disabled` rather than `disabled` and refuses the pick in its own handler: `disabled` would take it out of the tab order, and with the reason off the face that would leave anyone without a pointer unable to reach it at all.
+
+**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand.
+
+The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice.
+
+## Alternatives considered
+
+**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict.
+
+**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost.
+
+**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design.
+
+**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one.
+
+**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp.
+
+**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine.
+
+**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part.
+
+**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops.
+
+**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way.
+
+**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line.
+
+**Make the badge itself the focusable control.** Rejected: the badge sits inside the card's own `<button>`, so a focusable trigger there means restructuring the card head. Keeping the card focusable through `aria-disabled` reveals the same tooltip from the same key press and changes no layout. Leaving the reason reachable by pointer alone was rejected too — it was visible without any interaction before this change, so hiding it behind hover is a regression for anyone reading by keyboard rather than a path that never existed.
+
+## Consequences
+
+A preset naming a package that a rename or an uninstall took away is marked on the roster, refused before a mount is spent, and dropped from the pickers — the same treatment a ghost directory already got. The reason names each row at fault, and a failure that survives to mount names every row inside a group rather than the group alone.
+
+Health answers from what is installed, not from what would import: a package present but exporting a file that is missing still reports healthy and still fails at mount. That is the safe direction — under-reporting returns the previous behavior, while a false broken makes a usable preset unselectable — and it keeps the answer out of the build state of any one package. A source checkout is still not an installed host, though, because a shipped row names a package the deployment installs beside the roster: `shipped-root.spec.ts` asserts the shipped presets carry no reason other than unresolved rows rather than no reason at all. The mount fixtures name a module that loads and then refuses, since a fixture naming a file that does not exist can no longer reach the mount.
+
+A mount failure is now legible where it happens, which matters most for the failures health can never catch: a row that resolves and then refuses is reported healthy on the roster forever, so the banner is not a convenience over the card — it is the only account of that failure anywhere.
+
+Measured in the web app on a roster of eleven presets, `agentPreset.list` answers in 14ms cold and 6-8ms after, and the three concurrent reads the client opens with settle in 9ms of wall clock. The same reads took 2.45 seconds each while every row went through the resolver.
+
+`@deepseek-ai/cordis-plugin-group` is a devDependency of `dsh-agent-presets`: the mount fixtures now compose through `cordis:group` the way real presets do, and a preset outside the workspace cannot resolve that package by name, so the app registers it as a builtin and the fixture harness does the same.

+ 75 - 0
.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md

@@ -0,0 +1,75 @@
+# Agent Note: Preset health resolves the rows it can prove will start
+
+Status: implemented
+
+[English](2026-08-26-preset-health-resolves-rows.md) | 中文
+
+## Problem
+
+名单列为健康的 preset,仍可能根本无法组装。发现过程的健康检查只证明组装能以加载器方言解析、由具名行组成,并刻意止步于此——它不解析任何插件名,也不应用任何配置。
+
+本 note 部分取代了[损坏的 preset 是名单行](../bug-fix/2026-08-09-broken-preset-roster-rows.zh.md):那份 note 在 Alternatives 中否决的「深度校验」正是这里落地的做法,而且原因也已移出卡片正面;它同时放宽了[插件自带内置 preset 根](../bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md)记录的随附名单断言。两份都已就地更新。
+
+但 `broken` 是承重的,不是卡片上的装饰。`presetOptions` 会把损坏的行从会话选择器里滤掉,好让选择的人不必等到会话启动失败才发现;`resolveMountable` 会在花费一次挂载之前拒绝它。因此下游一切都把「不是 broken」读作「能组装」。
+
+这个缺口在[仓库命名契约](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)按预发布立场重命名包时暴露出来。仓库内的引用随之更新;写在 `<dshHome>/.agent-presets` 下的 preset 没有,于是引用 `@deepseek-ai/dsh-workspace-context` 的那一个保住了健康的卡片、保住了在选择器里的位置,直到有人切换过去才失败。引用了被后续版本改名或卸载的包,正是手写 preset 真正的腐化方式,而它恰好是这项检查排除掉的那一类。
+
+而它真正产出的失败,说得比它知道的还少。加载器的逐行包装构造一个普通 `Error`,其 message 以 `cause.message` 结尾,cause 只留在 `error.cause` 上。于是一个有两行失败的 group,抵达时是一行被包装的行,message 为 `failed to apply loader entry <group> (cordis:group): loader entries failed to apply`,两条真正的原因只能经由 `cause.errors` 取得。挂载诊断只展平 `AggregateError.errors`,从不跟随 `cause`,因此它停在那一行,一行都没点名。
+
+## Decision
+
+**发现过程解析每一行它能证明会启动的行,且不 import 任何东西。** 解析这一趟跑在 `packages/preset/agent-presets/src/discovery.ts` 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 `node_modules`、停在 `<包>/package.json` 的做法。preset 相对路径与绝对路径改用 stat,因为对这两类 `import.meta.resolve` 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
+
+用磁盘查找而不是 `import.meta.resolve`,有两个理由。它便宜:只要注册了 ESM loader hook,每一次解析器调用就变成一次到 hooks 线程的同步往返,在源码启动所用的 `tsx` hook 下实测命中 2ms、未命中 5ms,而裸 Node 分别是 0.055ms 与 0.032ms——每次名单读取要背上 238ms 的解析器时间,而同样这 135 行磁盘走法只要 0.7ms。它也是唯一问得到「宿主」的:`import.meta.resolve` 的 `parentURL` 参数只在 `--experimental-import-meta-resolve` 下生效,而没有任何启动方式传它,因此它是相对调用方模块解析的,回答的是关于本包而不是关于部署的问题。真正认显式 parent 的是 Loader 的内部解析器,而它的 `resolveSync` 在 Node 22 与 24 上签名不同。Node 内建模块在磁盘查找之前直接短路。
+
+磁盘走法放弃了什么:只有经由 loader hook 才能解析的包——import map,或根本没有 `node_modules` 的目录树——会被报为损坏。任何受支持的安装都不会产出这种情况,因为 `dsh plugin install` 会把每个插件装在名单旁边。
+
+**只有一个分类器决定一行在哪里解析。** `src/specifier.ts` 拥有这个划分——`cordis:` 内建、preset 相对、绝对文件、包名——挂载的 import 覆写与发现过程的检查都读它。若发现过程按一个基准解析、而挂载按另一个基准 import,那一行会被报告为健康,然后加载失败。
+
+**可能永远不会启动的行被跳过。** `disabled` 是[加载器唯一会插值](2026-08-11-loader-entry-disabled-interpolation.zh.md)的条目字段:`!!js` 表达式在挂载时对加载器上下文求值,而发现过程无法仅凭文件做到。凡该字段不是缺失、null 或 `false` 的行都不做检查,被禁用的 group 连同其子行一起跳过。每个随附 preset 都用这种方式为 shell 行设门,所以这是常见形状,不是边角。
+
+**harness base 是必填参数。** `discoverPresets(roots, harnessBase)` 与 `scanRoot(root, harnessBase)` 都接收它;`AgentPresets` 在构造函数里读一次 `ctx.baseUrl`,缺失就抛。基准正是让这个问题可回答的前提——同一个包名从 preset 自己的目录解析会失败、从已安装的 harness 解析会成功——所以做成可选就等于悄悄恢复这项检查要终结的那个状态。
+
+**挂载诊断跟随携带信息多于自身 message 的 cause。** `mountDetail` 从 `AggregateError.errors` 取分支,或在 cause 是 `AggregateError` 时从 `error.cause.errors` 取;普通的 cause 链已被展平进 message,不再跟随,否则每一行都会打印两遍。嵌套分支在拥有它的那一行下缩进。
+
+**客户端把原因放到徽标上。** 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因在悬停徽标或聚焦卡片时展开,另有一个视觉隐藏的 `role="alert"` 节点负责朗读。损坏的卡片用 `aria-disabled` 而非 `disabled` 表达这件事,并在自己的处理函数里拒绝这次选择:`disabled` 会把它移出 tab 序列,而原因已不在正面,那等于让不用指针的人完全够不到它。
+
+**被拒绝的切换要在被拒绝的地方说明原因。** chip 的标签会弹回会话仍在运行的那个 preset,因此不说话的话,这次选择看起来就像根本没发生。它经由共享的 `Toast` 在 composer 列上方自报,与旁边的模型选择器报告被拒绝的选择方式一致。只有人刚做出的选择会被自报——应用器在会话成为当前会话时也会运行,为那种情况弹横幅等于报告一个没人问过的拒绝。横幅停留八秒而非 primitive 默认的三秒,因为它承载的原因要点名包与行;`Toast` 为此获得了 `holdMs`,顺带也消除了「停留常量必须由人手与样式表保持同步」这一隐患。
+
+线上本来就把这需要的两段文本分开了:`message` 把原因裹进名单自己的「preset X failed to mount」框架,而 `details.reason` 只保留原因本身。自己会点名 preset 的表面取后者,否则会把 preset 说两遍。
+
+## Alternatives considered
+
+**在选中 preset 时检查,而不是在列出名单时。** 否决。选择器在任何人选中之前就按 `broken` 过滤,因此只在选中时检查的 preset 仍会被摆出来,报出的失败仍在点击之后到达——原本的抱怨只是换了个位置。名单行才是每个消费者已经在读判定的地方。
+
+**让 base 可选,没有它就跳过检查。** 否决。它的失败模式正是要修的这个 bug,而且不带任何信号:无法组装的 preset 顶着健康卡片。`ctx.baseUrl` 在任何作用域上下文派生之前就设在根上,因此这个抛出是对「不会发生的事」的断言,而不是一条有运行时代价的分支。
+
+**import 每一行而不是解析它。** 否决。import 会在每次读取名单时执行模块顶层代码,这是选择器不该有的副作用,而且那是挂载的职责——在 apply 时抛错、或永远等待某个服务的插件仍按设计在第一个会话处失败。
+
+**每一行都交给 `import.meta.resolve` 解析。** 先这样发出去,实测后回退:它是对的,但每次名单读取要 445ms,而客户端并发的三次读取把它放大成每次 2.45 秒——设置分区肉眼可见地卡住。解析器确实是「什么能 import」的权威,但为明摆着装好的行去问它,等于为每一行付一次 hooks 线程往返。
+
+**把整个 `compositionProblem` 缓存在已有的 `CompositionStamp` 上。** 作为省开销的手段被否决:它只能让重复读取免费,每个被编辑过的组装的第一次读取仍是全价;而且它把解析结果挂在组装文件上,可安装状态变化时组装文件并不会变。改用磁盘查找直接消掉了开销,于是没有什么还需要这个 stamp。
+
+**把切换失败送到名单卡片上,而不是弹横幅。** 否决:卡片恰恰是那些能走到挂载的失败看不见的地方。所有行都能解析的组装会被报告为健康,于是「去设置页看原因」指向的是一张写着「这个 preset 没问题」的卡片。
+
+**只报第一个无法解析的行,与形状检查保持一致。** 否决。解析失败会连锁,所以在那里只点名一个是诚实的;无法解析的名字是彼此独立、一次即可全部知晓的事实,而一次重载只修一个才是可以避免的部分。
+
+**在 `mountDetail` 里无条件跟随 `error.cause`。** 否决。加载器的包装已经把 `cause.message` 追加进它构造的 message,因此普通链会把每一行渲染两遍。`AggregateError` 类型的 cause 是唯一被 message 丢掉细节的形状。
+
+**继续把原因渲染在卡片正面。** 否决。原因里是包说明符和路径,把它们摆在 preset 描述的位置,等于用选择者需要的东西换取修复者需要的东西——而修复者需要的那份,无论如何都只隔一次悬停。
+
+**复用图标行的 `data-tip` 伪元素来做提示条。** 实测后否决:生成内容会并入元素的可访问文本,因此卡片的 aria 快照多出一份 alert 已经携带的原因的逐字副本。改用真实的 `aria-hidden` 元素后,可访问副本恰好只有一份——而且既有那条提示条是为图标标签准备的单行 `nowrap`,这一条要逐行列出包说明符。
+
+**把徽标本身做成可聚焦控件。** 否决:徽标位于卡片自身的 `<button>` 内部,在那里放可聚焦触发器意味着改造卡片头部结构。用 `aria-disabled` 让卡片保持可聚焦,同一次按键就能展开同一条提示条,且不改动任何布局。「原因只用指针可达」同样被否决——这次改动之前它无需任何交互就可见,因此把它藏到悬停之后,对用键盘阅读的人是退化,而不是一条本就不存在的路径。
+
+## Consequences
+
+引用了被改名或卸载掉的包的 preset,会在名单上被标出、在花费挂载之前被拒绝、并从选择器里剔除——与幽灵目录早已得到的待遇一致。原因会点名每一个出问题的行;而活到挂载阶段的失败,会点名 group 内的每一行,而不只是 group 本身。
+
+健康依据的是「装没装」,而不是「能不能 import」:包在、但导出指向的文件不在,仍会报告健康并仍在挂载时失败。这是安全的方向——漏报只是退回先前的行为,而误报会让一个可用的 preset 变得不可选——而且它让答案不依赖于任何单个包的构建状态。不过源码检出仍不是已安装宿主,因为随附行引用的是部署与名单装在一起的包:`shipped-root.spec.ts` 断言随附 preset 除未解析行之外不携带其他原因,而不是不携带任何原因。挂载夹具改为引用一个能加载、随后拒绝的模块,因为引用不存在文件的夹具已经到不了挂载。
+
+挂载失败现在在它发生的地方就可读,这对健康永远抓不到的那类失败最要紧:能解析、随后拒绝的行在名单上永远显示健康,因此这条横幅不是卡片之外的一份便利——它是那类失败在任何地方唯一的交代。
+
+在 web 应用中对十一个 preset 的名单实测:`agentPreset.list` 冷启动 14ms、之后 6–8ms,客户端开场并发的三次读取合计 9ms wall。在每一行都过解析器的版本里,同样这三次读取各要 2.45 秒。
+
+`@deepseek-ai/cordis-plugin-group` 成为 `dsh-agent-presets` 的 devDependency:挂载夹具现在像真实 preset 那样经由 `cordis:group` 组装,而工作区之外的 preset 无法按名解析该包,所以应用把它注册为内建,夹具 harness 也照做。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
-2026-08-09-broken-preset-roster-rows.md: 069585957d4d99598cc38e4a7c6bc8c8d82490ca
-2026-08-09-broken-preset-roster-rows.zh.md: 3bd60403edcce8ba7668c66561ce24c4d08b57e8
+2026-08-09-broken-preset-roster-rows.md: 4ae92f0b12f2ebede925d6047b358b8cafb10149
+2026-08-09-broken-preset-roster-rows.zh.md: 60d1ff533e3e968e04dad8526042fe4579752569

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md

@@ -12,7 +12,7 @@ With files as the only composition editor, hand-edit damage had two failure shap
 
 Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
 
-Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
+Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too; the reason itself has since moved off the card face onto the badge, see the note above), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
 
 ## Consequences
 
@@ -30,4 +30,4 @@ Surfaces split by their job: the management section renders broken rows as marke
 
 ## Alternatives considered
 
-Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.
+Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Partly superseded — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) now RESOLVES each such row without importing it, which is cheap enough to run on every read; the mount still owns the failures resolution cannot see. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
 
-界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
+界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给;原因本身此后已从卡片正面移到徽标上,见上述 note),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
 
 ## 后果
 
@@ -30,4 +30,4 @@ Status: implemented
 
 ## 曾考虑的替代方案
 
-隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。
+隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。已被部分取代——[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)现在会**解析**每一个这样的行而不 import 它,便宜到足以每次读取都跑;挂载仍然拥有解析看不见的那些失败。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
-2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d
-2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773
+2026-08-20-plugin-owned-shipped-preset-root.md: 5af1d213f62acfc92ba053e7246adf55e32b4c34
+2026-08-20-plugin-owned-shipped-preset-root.zh.md: e5f2e08e221258899433f363109fb385b7472dc6

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md

@@ -18,7 +18,7 @@ This completes the [per-session preset roster](../architecture/2026-08-03-per-se
 
 ## Testing
 
-`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
+`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets `system`-trusted and carrying no reason other than unresolved rows (proving the moved files resolve from the package). Health has since grown a module-resolution pass — [preset health resolves the rows it can prove will start](../architecture/2026-08-26-preset-health-resolves-rows.md) — and a fixture base is not the install a shipped row's packages sit in, so the assertion names the reason it tolerates rather than requiring none, the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 ## 测试
 
-`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
+`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 为 `system` 信任、且除未解析行外不携带其他原因(证明搬移后的文件能从包内解析)。健康检查此后新增了一趟模块解析——见[预设健康解析它能证明会启动的行](../architecture/2026-08-26-preset-health-resolves-rows.zh.md)——而 fixture 基准并不是内置行所引用的包所在的那个安装,因此该断言点名它容忍的原因,而不是要求一个都没有;内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.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-08-26-composer-gesture-editable-gate.md
+2026-08-26-composer-gesture-editable-gate.md: 9b01a79d8d8a546641eaf87040d7a19fc5b4d5bc
+2026-08-26-composer-gesture-editable-gate.zh.md: b5dc8d36c7b195a152d32e92fac7ae36eea880d9

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e gestures gate on the contenteditable attribute
+
+Status: implemented
+
+English | [中文](2026-08-26-composer-gesture-editable-gate.zh.md)
+
+## Problem
+
+Two Playwright gesture semantics silently changed when the composer became a Lexical `contenteditable` `<div>`, and both bit only under CI load. While the input machine is adjudicating or submitting a send — and in every locked state — the composer renders read-only by flipping `contenteditable` to `"false"` on the same element. On that element `fill()` throws immediately (`Element is not an <input>, <textarea> or [contenteditable] element`) instead of waiting through actionability, and `expect.poll(() => input.isEnabled())` is a no-op guard: Playwright's enablement check ignores both `aria-disabled` and `contenteditable` on a `<div>`, so it reports `true` throughout the read-only window. The exposed race is only a few frames wide — the permission-policy scenario stayed green for weeks until a Remote-routed subagent refactor stretched submit settling enough for CI to land inside it.
+
+## Decision
+
+Composer e2e gestures go through `writeComposerDraft` in `apps/web/tests/support.ts`, which waits for the editable attribute on the gesture's own target (`input.and(page.locator('[contenteditable="true"]'))`) before acting and replaces the draft with per-key strokes. Scenario code that must wait for the composer to reopen after a submit gates on the `contenteditable` attribute, never on `isEnabled()`.
+
+## Alternatives considered
+
+- **Waiting inside each scenario** instead of inside the helper: rejected — every new scenario re-discovers the trap the hard way, and the fix that motivated this note was already the second such site.
+- **Keeping `fill()` and polling `aria-disabled`** before each call: rejected — it leaves the dropped-edit race `fill()` has directly after trigger-menu and chip interactions (Lexical's internal selection lags the DOM selection inside one task), which the per-key helper also covers.
+- **Making the product surface tolerate `fill()`** (accepting synthetic edits while read-only): rejected — the read-only window is deliberate UI truth during submit adjudication; loosening it for tests would change user-visible behavior.
+
+## Consequences
+
+- A bare `input.fill(...)` against `[data-composer-input]` is a latent CI race even when it passes locally; the helper is the supported gesture.
+- `isEnabled()` on the composer asserts nothing. Existing polls of it guard nothing and read as coverage they do not provide.
+- A running turn by itself keeps the composer editable — that is what queueing types into — so the gate waits only through submit adjudication and locked states, not for turn completion.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e 手势以 contenteditable 属性为门
+
+Status: implemented
+
+[English](2026-08-26-composer-gesture-editable-gate.md) | 中文
+
+## 问题
+
+composer 变为 Lexical `contenteditable` `<div>` 后,两个 Playwright 手势语义悄然改变,且都只在 CI 高负载下咬人。输入机在裁决或发送一次提交期间——以及所有 locked 状态下——composer 通过把同一元素的 `contenteditable` 翻成 `"false"` 呈现只读。在该元素上 `fill()` 立即抛错(`Element is not an <input>, <textarea> or [contenteditable] element`)而不再经 actionability 等待;`expect.poll(() => input.isEnabled())` 则是无效护栏:Playwright 的 enablement 检查对 `<div>` 同时无视 `aria-disabled` 与 `contenteditable`,整个只读窗口内一律报 `true`。暴露的竞态只有几帧宽——permission-policy 场景绿了数周,直到 subagent 控制 Remote 化把提交 settle 拉长,CI 才落进窗口。
+
+## 决策
+
+composer 的 e2e 手势统一走 `apps/web/tests/support.ts` 的 `writeComposerDraft`:动作前在手势自身的目标上等待可编辑属性(`input.and(page.locator('[contenteditable="true"]'))`),再以逐键击键替换草稿。场景代码若需等待提交后 composer 重新开放,一律以 `contenteditable` 属性为门,永不使用 `isEnabled()`。
+
+## 曾考虑的替代方案
+
+- **在各场景内各自等待**而不是收进 helper:否决——每个新场景都会以最痛的方式重新发现这个陷阱,而促成本 note 的修复本身已是第二个踩点。
+- **保留 `fill()`、每次调用前 poll `aria-disabled`**:否决——这仍留着 `fill()` 在触发菜单与 chip 交互之后的丢编辑竞态(单 task 内 Lexical 内部 selection 落后于 DOM selection),逐键 helper 同时覆盖了它。
+- **让产品表面容忍 `fill()`**(只读期间接受合成编辑):否决——只读窗口是提交裁决期间刻意的 UI 事实;为测试放松它会改变用户可见行为。
+
+## 后果
+
+- 对 `[data-composer-input]` 裸写 `input.fill(...)` 即使本地全绿也是潜伏的 CI 竞态;helper 是受支持的手势。
+- 对 composer 调用 `isEnabled()` 断言不了任何东西。既有的此类 poll 不护任何路径,却读起来像提供了覆盖。
+- turn 运行本身保持 composer 可编辑——排队输入正是打进这里——因此该门只等待提交裁决与 locked 状态,不等待 turn 完成。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.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-08-26-pi-ai-grant-payload-json-image.md
+2026-08-26-pi-ai-grant-payload-json-image.md: 9eb5e5ffd9ccbc18d409bd50e05061006bdca094
+2026-08-26-pi-ai-grant-payload-json-image.zh.md: 8329b4acf062a073dfe78ec10fe03852e7a64fd9

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md

@@ -0,0 +1,29 @@
+# Agent Note: pi-ai grant payloads store their JSON image
+
+Status: implemented
+
+English | [中文](2026-08-26-pi-ai-grant-payload-json-image.zh.md)
+
+## Problem
+
+A GitHub Copilot sign-in against github.com failed at its commit step: `credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`. pi-ai's Copilot credential carries its optional members as explicit `undefined` (`enterpriseUrl: undefined` when no Enterprise domain was given — idiomatic JavaScript that `JSON.stringify` would simply drop), and `llm-pi-ai`'s store bridge committed the credential object verbatim as the grant payload. The credential store's validator rightly refuses `undefined` as unrepresentable, so every grant whose flow left an optional member unset failed to store, and the sign-in reported failure after the provider had already authorized it.
+
+## Decision
+
+`toRecord` in `packages/llm/llm-pi-ai/src/auth.ts` stores the JSON image of a grant credential: `jsonImage` drops explicitly-undefined members of plain objects and renders undefined array entries as `null`, exactly as `JSON.stringify` would. Everything else — non-finite numbers, foreign-prototype objects — passes through untouched, so a genuinely unstorable value still fails loud at the store's validator rather than being silently reshaped. Reading back is unchanged: an absent member and an explicitly-undefined one are indistinguishable to pi-ai's consumers, which access optional members by property read.
+
+## Testing
+
+`tests/auth.spec.ts` writes the Copilot-shaped grant (explicit `undefined` member, nested drop, array hole) through the real `LocalCredentialProvider` and asserts the stored payload is the JSON image; a second case proves the fail-loud path survives by committing a `Date`-valued member and asserting the store's refusal reaches the caller.
+
+## Alternatives considered
+
+**`JSON.parse(JSON.stringify(credential))`.** Rejected: it also renders `NaN`/`Infinity` as `null` and runs `toJSON` methods, silently reshaping exactly the values the strict validator exists to refuse loudly.
+
+**Relaxing the store validator to skip `undefined` members.** Rejected: the seam stores payloads it never reads or reshapes, and every producer relies on byte-faithful round-trips; normalization belongs to the producer that knows its library's idiom, not to the store every plugin shares.
+
+**Fixing pi-ai upstream to omit unset members.** Out of this repository's hands and version-fragile: any future flow reintroducing the idiom would break sign-in again. The bridge owning the translation makes the harness robust against the whole class.
+
+## Consequences
+
+Grants from every pi-ai flow store regardless of which optional members the flow left unset. The bridge now owns a one-way normalization: a payload read back lacks members that were explicitly `undefined` at write time, which is indistinguishable from their absence for property access, and remains the documented JSON semantics.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: pi-ai grant payload 落盘其 JSON 像
+
+Status: implemented
+
+[English](2026-08-26-pi-ai-grant-payload-json-image.md) | 中文
+
+## Problem
+
+一次面向 github.com 的 GitHub Copilot 登录在提交环节失败:`credentials-local: record "llm-pi-ai/github-copilot" payload holds a value JSON cannot represent`。pi-ai 的 Copilot 凭据以显式 `undefined` 携带可选成员(未填 Enterprise 域名时为 `enterpriseUrl: undefined`——这是 `JSON.stringify` 会直接丢弃的 JavaScript 惯用写法),而 `llm-pi-ai` 的存储桥接把凭据对象原样作为 grant payload 提交。凭据存储的校验器正当地拒绝 `undefined` 为不可表示,于是所有流程留有未填可选成员的 grant 都无法落盘,提供方已经完成授权之后登录却报失败。
+
+## Decision
+
+`packages/llm/llm-pi-ai/src/auth.ts` 的 `toRecord` 改为落盘 grant 凭据的 JSON 像:`jsonImage` 丢弃普通对象里显式为 undefined 的成员,把数组中的 undefined 条目渲染为 `null`,与 `JSON.stringify` 完全一致。其余一切——非有限数、异种原型对象——原样透传,因此真正不可存储的值仍会在存储校验器处大声失败,而不是被静默改写。读回不变:成员缺失与显式 undefined 对以属性读取访问可选成员的 pi-ai 消费方不可区分。
+
+## Testing
+
+`tests/auth.spec.ts` 经真实 `LocalCredentialProvider` 写入 Copilot 形状的 grant(显式 `undefined` 成员、嵌套丢弃、数组空洞),断言落盘 payload 为 JSON 像;第二个用例提交带 `Date` 成员的 grant 并断言存储的拒绝到达调用方,证明 fail-loud 路径仍在。
+
+## Alternatives considered
+
+**`JSON.parse(JSON.stringify(credential))`。**否决:它还会把 `NaN`/`Infinity` 渲染为 `null` 并执行 `toJSON` 方法,把严格校验器本要大声拒绝的值静默改写掉。
+
+**放宽存储校验器、跳过 undefined 成员。**否决:seam 存储的 payload 它从不读取或改写,所有生产方都依赖逐字节往返;归一化属于了解自家库惯用法的生产方,而不是所有插件共享的存储。
+
+**在 pi-ai 上游修掉未填成员。**不在本仓库掌控内且随版本脆弱:将来任何流程重新引入该惯用法都会再次弄坏登录。由桥接层拥有这次翻译,让 harness 对整类问题免疫。
+
+## Consequences
+
+所有 pi-ai 流程的 grant 无论留空哪些可选成员都能落盘。桥接层现在拥有一次单向归一化:写入时显式为 undefined 的成员在读回时缺失,这对属性访问不可区分,且正是文档化的 JSON 语义。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.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-08-26-question-drafts-survive-session-switch.md
+2026-08-26-question-drafts-survive-session-switch.md: 2279c4efd5e79c51a0e2e2347f21fae4b0a47b7a
+2026-08-26-question-drafts-survive-session-switch.zh.md: af275a704bef723cb3ed6fde83138a2be5b5cb3f

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md

@@ -0,0 +1,39 @@
+# Agent Note: Question drafts survive Session switches
+
+Status: implemented
+
+English | [中文](2026-08-26-question-drafts-survive-session-switch.zh.md)
+
+## Problem
+
+`conversation.composer` is a strict Session-scoped slot, so selecting another Session unmounts its question entry. The generic `QuestionFlow` kept its current question index, selected labels, custom text, and skip flags in React component state. A still-pending request therefore returned with empty answers after an A → B → A Session switch even though the pending carrier remained owned by Session A.
+
+The draft is transient presentation state: it must follow its Session within the current page, but it must not become mutable state on the pending business carrier or a user preference synchronized through Host settings.
+
+## Decision
+
+The question entry declares a non-persisted `createQuestionDraftStore` handle when it registers into `conversation.composer`. The renderer owns one instance per Session scope and retains that instance across selection changes, so remounting the same Session reads the same progress.
+
+The store holds at most one request identity and one progress value: current question index plus one selected/custom/skipped draft per question. `QuestionFlow` reads the stored value only when the local pending-request key and question count match. A new request therefore renders empty immediately and its first write atomically replaces the older value instead of accumulating request records. Successful answer and cancellation settlements clear only their matching request key, so a stale completion cannot erase a later draft.
+
+Busy state, failure feedback, collapse state, and focus bookkeeping remain component-local because they describe the mounted interaction rather than the unfinished answer. The `plan-review` presentation has no multi-question draft and does not read the store.
+
+This realizes the existing [Session-scope rule](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) that remount-surviving state belongs in a Session-bound source, while retaining the [Host-backed preference decision](2026-08-06-host-backed-web-preferences.md): drafts remain page-local and never enter settings, `localStorage`, or disk. The answer semantics from [multi-select custom composition](2026-07-30-multi-select-custom-answer-composition.md) are unchanged.
+
+## Testing
+
+The store test pins keyed replacement and stale-cleanup isolation. The component test unmounts and remounts the strict entry over one store instance and requires its page, selected option, and custom text to return. The keyless assembled Web scenario types both answer forms, switches to a new Session, returns to the waiting Session, snapshots the restored composer, and submits the restored values through the real question waterfall.
+
+## Alternatives considered
+
+**Keep the state in `QuestionFlow`.** Rejected because a strict Session switch deliberately destroys that React instance; a component-local key cannot outlive the unmount it is intended to identify.
+
+**Put mutable drafts on `PendingQuestion`.** Rejected because the carrier represents pending request settlement, not React presentation state, and mutations there would bypass the Slot store's subscribed read/write surface and lifecycle ownership.
+
+**Use a module-level map keyed by Session and request.** Rejected because plugin reload and Session pruning would not own its disposal, and completed request entries could accumulate independently of the renderer's scope lifecycle.
+
+**Persist drafts through Host settings or browser storage.** Rejected because switching Sessions within one page needs remount continuity, not cross-page or cross-process durability. Persistence would synchronize transient answer text beyond the interaction that owns it.
+
+## Consequences
+
+Unsubmitted generic-question answers survive ordinary Session navigation in the current page, including the current question and explicit skips. They still reset after a page reload, Session-scope prune, or replacement pending-request identity. The per-Session memory cost is bounded to one request progress value and is released with the Slot store's Session scope.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 提问草稿在 Session 切换后保留
+
+Status: implemented
+
+[English](2026-08-26-question-drafts-survive-session-switch.md) | 中文
+
+## Problem
+
+`conversation.composer` 是严格按 Session 划分 scope 的 slot,因此选择另一个 Session 会卸载其提问条目。通用 `QuestionFlow` 把当前题号、已选标签、自定义文本和跳过标记保存在 React 组件状态中。因此,即使待处理载体仍归 Session A 所有,一个仍在等待的请求经过 A → B → A 的 Session 切换后,也会以空答案重新出现。
+
+草稿是临时呈现状态:它必须在当前页面内跟随所属 Session,但不能变成待处理业务载体上的可变状态,也不能成为通过 Host settings 同步的用户偏好。
+
+## Decision
+
+提问条目注册到 `conversation.composer` 时声明一个非持久化的 `createQuestionDraftStore` handle。renderer 为每个 Session scope 拥有一个实例,并在选择切换期间保留该实例,因此重新挂载同一 Session 时会读到相同进度。
+
+store 最多保存一个请求标识和一个进度值:当前题号,以及每道题各一份 selected/custom/skipped 草稿。只有本地待处理请求 key 与题目数量都相符时,`QuestionFlow` 才读取已存值。因此,新请求会立即渲染为空,并在首次写入时原子替换旧值,而不会累积请求记录。成功回答和取消落定后只清除与自身相符的请求 key,因此过期的完成动作不会删除较新的草稿。
+
+忙碌状态、失败提示、折叠状态和焦点记录仍留在组件本地,因为它们描述当前已挂载交互,而不是未完成的答案。`plan-review` 呈现界面没有多题草稿,也不读取该 store。
+
+这落实了既有的 [Session scope 规则](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md):需要跨重新挂载保留的状态应归 Session 绑定的数据源;同时保留[由 Host 持久化偏好的决策](2026-08-06-host-backed-web-preferences.zh.md):草稿仍只存在于当前页面,从不进入 settings、`localStorage` 或磁盘。[多选自定义答案组合](2026-07-30-multi-select-custom-answer-composition.zh.md)规定的答案语义保持不变。
+
+## Testing
+
+store 测试固定按 key 替换和过期清理隔离。组件测试在同一个 store 实例上卸载并重新挂载严格 Session 条目,并要求题号、已选选项和自定义文本全部恢复。无密钥的组装 Web 场景会输入两种答案、切换到新 Session、返回仍在等待的 Session、对恢复后的编辑器生成快照,再经真实提问 waterfall 提交恢复的值。
+
+## Alternatives considered
+
+**继续把状态留在 `QuestionFlow`。** 不采用,因为严格 Session 切换会刻意销毁该 React 实例;组件本地 key 无法比其试图标识的卸载过程活得更久。
+
+**把可变草稿放进 `PendingQuestion`。** 不采用,因为载体表示待处理请求的落定过程,而不是 React 呈现状态;在其中做变更还会绕过 Slot store 提供的订阅读写界面和生命周期归属。
+
+**使用按 Session 和请求建立索引的模块级 map。** 不采用,因为 plugin 重载与 Session 裁剪不拥有其清理过程,已完成请求的条目还可能脱离 renderer 的 scope 生命周期不断累积。
+
+**通过 Host settings 或浏览器存储持久化草稿。** 不采用,因为同一页面内切换 Session 需要的是跨重新挂载连续性,而不是跨页面或跨进程耐久性。持久化会把临时答案文本同步到拥有它的交互之外。
+
+## Consequences
+
+未提交的通用提问答案现在能在当前页面的普通 Session 导航中保留,包括当前题号和显式跳过状态。刷新页面、Session scope 被裁剪或待处理请求标识被替换后,草稿仍会重置。每个 Session 的内存成本被限制为一个请求进度值,并随 Slot store 的 Session scope 一起释放。

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: 98f48029a86a8b07e53cd4498b27d637508e450b
-2026-08-08-native-windows-pull-request-ci.zh.md: b2dba91e4a88d0da637521aae2de937672b869a3
+2026-08-08-native-windows-pull-request-ci.md: 8faec10fdc8538c994954213ac477a2ed52e800c
+2026-08-08-native-windows-pull-request-ci.zh.md: f692aa85e33e2e02d04cd72d9316ed4fc139aced

Разлика између датотеке није приказан због своје велике величине
+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md


Разлика између датотеке није приказан због своје велике величине
+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md


+ 2 - 2
.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.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/process/2026-08-20-doc-site-raw-markdown-twins.md
-2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2
-2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4
+2026-08-20-doc-site-raw-markdown-twins.md: 7f557d1327ce4d981e37c1281a8d58526303f815
+2026-08-20-doc-site-raw-markdown-twins.zh.md: 0fd7e90cf7119b49576ea68e89e7f73ccecfe4a5

+ 3 - 1
.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md

@@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h
 
 One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure.
 
-An index route renders as a directory URL, so "append `.md`" lands on `<dir>.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission.
+An index route renders as a directory URL, so "append `.md`" lands on `<dir>.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`.
+
+Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and requires its nearest existing parent to resolve inside the real site root. It unlinks a link-shaped output without traversing its target and removes a real output directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission.
 
 `llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label](<base><route>): <section>` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point.
 

+ 3 - 1
.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md

@@ -14,7 +14,9 @@ Status: implemented
 
 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。
 
-index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。
+index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。
+
+每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并要求它最近的现有父目录必须解析到站点根目录的真实路径以内。构建会直接解除链接型输出而不遍历其目标,并在打包前删除真实输出目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。
 
 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label](<base><route>): <section>`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。
 

+ 6 - 7
.github/workflows/build-exe-for-python-sdk.yml

@@ -4,9 +4,10 @@ name: Build single-exe
 # .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
 # A full target run retains one SDK wheel and four runtime wheels; subset
 # dispatch retains the SDK wheel and selected runtime wheels. Bare executables
-# and source closures are test inputs. Run manually, label a PR `build-exe`
-# (remove and reapply to rerun), or call it from the Python release workflow.
-# Checkout uses the triggering ref, so dispatch needs no separate ref input.
+# and source closures are test inputs. Run manually or call it from the Python
+# release workflow. There is no `pull_request` trigger: a label trigger would
+# list gray skipped checks on every unrelated PR label event. Checkout uses the
+# triggering ref, so dispatch needs no separate ref input.
 on:
   workflow_call:
     inputs:
@@ -39,8 +40,6 @@ on:
         type: string
         required: false
         default: ''
-  pull_request:
-    types: [labeled]
 
 concurrency:
   # Keep the called workflow distinct from its caller's concurrency group;
@@ -62,7 +61,7 @@ jobs:
   # construct the matrix before the dependent jobs.
   plan:
     name: plan targets
-    if: inputs.ci || inputs.release || github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
+    if: inputs.ci || inputs.release || github.event_name == 'workflow_dispatch'
     runs-on: ubuntu-latest
     timeout-minutes: 5
     outputs:
@@ -89,7 +88,7 @@ jobs:
       - name: Compute matrix from targets input
         id: plan
         env:
-          # Label runs and blank dispatch inputs build all targets.
+          # Blank dispatch inputs build all targets.
           TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64' }}
         run: |
           set -euo pipefail

+ 1 - 1
.github/workflows/ci.yml

@@ -452,7 +452,7 @@ jobs:
     timeout-minutes: 120
     env:
       DSH_COVERAGE_MAX_WORKERS: '6'
-      DSH_COVERAGE_PARTITIONS: '6'
+      DSH_COVERAGE_PARTITIONS: '4'
       DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
       DSH_GATE_CONCURRENCY: '3'
     steps:

+ 53 - 10
apps/web/tests/agent-preset-selection.e2e.ts

@@ -9,7 +9,8 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { fileURLToPath } from 'node:url'
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
@@ -22,7 +23,9 @@ import {
   captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
+} from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-selection', import.meta.url))
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
@@ -32,6 +35,26 @@ const MODE = webSnapshotMode()
 const SEED_ID = 'agent-preset-selection-web-e2e'
 /** A project skill only a preset that mounts `skill-filesystem` can discover. */
 const SKILL_NAME = 'preset-catalog-demo'
+/** The preset whose rows resolve and then refuse to start. */
+const REFUSING_ID = 'zz-refusing'
+
+/**
+ * Seed a preset discovery reports healthy and the mount refuses.
+ *
+ * Every row resolves — the module is right there beside the composition — so
+ * health has nothing to report and the chip offers the preset like any other.
+ * Only starting it finds out, which is the case the chip's banner exists for.
+ * @param root - the lane's writable preset root.
+ */
+async function seedRefusingPreset(root: string): Promise<void> {
+  const directory = join(root, REFUSING_ID)
+  await mkdir(directory, { recursive: true })
+  await writeFile(join(directory, 'refuses.mjs'),
+    'export const name = \'refuses\'\nexport function apply() { throw new Error(\'this row refuses to start\') }\n')
+  await writeFile(join(directory, 'agent.cordis.yml'), '- id: refuses\n  name: ./refuses.mjs\n')
+  await writeFile(join(directory, 'preset.yml'),
+    'name: Refusing mode\ndescription: Resolves, then refuses to start.\n')
+}
 
 /**
  * Seed one project skill under the connected workspace.
@@ -176,11 +199,16 @@ describe('web e2e: agent-preset selection', () => {
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
+  let presetRoot: string
 
   beforeAll(async () => {
-    // The scaffold's default roster pin is exactly this scenario's shape: the
-    // plugin's shipped presets, default `standard`.
-    scaffold = await launchWebScaffold({})
+    // The shipped presets, plus one lane-owned preset that mounts and refuses:
+    // the chip's own failure path needs a preset the roster offers.
+    presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-')))
+    await seedRefusingPreset(presetRoot)
+    scaffold = await launchWebScaffold({
+      agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' },
+    })
     // A resumed session runs what it was created with; seeding one that
     // records `minimal` is what makes the header label a claim about the
     // session rather than an echo of the current default.
@@ -237,8 +265,23 @@ describe('web e2e: agent-preset selection', () => {
     await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
   })
 
+  it('says why a switch was refused instead of letting the chip revert in silence', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-refused'))
+    await page.getByRole('button', { name: 'Minimal mode' }).click()
+    await page.getByRole('menuitem', { name: /Refusing mode/ }).click()
+
+    // Health cleared every row, so nothing on the settings page says this
+    // preset is unusable — the banner is where the host's reason lands, and
+    // without it the chip just snaps back to the preset it already ran.
+    const banner = page.getByRole('alert').filter({ hasText: 'Refusing mode' })
+    await banner.waitFor({ timeout: 15_000 })
+    expect(await banner.textContent()).toContain('this row refuses to start')
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
+    await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
+  }, 60_000)
+
   it('re-reads the slash catalog through the composition the switch installed', async () => {
-    // Continues the previous case: the chip has already applied `minimal` to
+    // Continues 'applies the staged pick': the chip has already applied `minimal` to
     // the blank session, and this one reads the menu that switch left behind.
     onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
     const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
@@ -246,7 +289,7 @@ describe('web e2e: agent-preset selection', () => {
     // `minimal` mounts neither the compaction group nor plan mode nor local
     // skill discovery, so the catalog the composer warmed under the
     // deployment default must not survive the switch.
-    await composer.fill('/')
+    await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
     const onMinimal = await menuOptions(page)
@@ -256,7 +299,7 @@ describe('web e2e: agent-preset selection', () => {
     // remains outside every preset.
     expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false)
     expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
-    await composer.fill('')
+    await writeComposerDraft(page, composer, '')
 
     // Switching back up reaches the host at all — the chip compares the pick
     // against its list row, so a row that never reprojected the first switch
@@ -266,14 +309,14 @@ describe('web e2e: agent-preset selection', () => {
     await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
     await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
 
-    await composer.fill('/')
+    await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
     const onStandard = await menuOptions(page)
     expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('goal'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
-    await composer.fill('')
+    await writeComposerDraft(page, composer, '')
   }, 90_000)
 
   it('labels a resumed session with the preset it was created under', async () => {

+ 1 - 0
apps/web/tests/expected/agent-preset-selection/menu.expected.md

@@ -5,3 +5,4 @@
   - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
   - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
   - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
+  - menuitem "Refusing mode Resolves, then refuses to start."

+ 7 - 7
apps/web/tests/permission-policy-context.e2e.ts

@@ -15,7 +15,7 @@ import {
   assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
   watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.jsonl', import.meta.url))
@@ -94,26 +94,26 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
       expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
     }
 
-    const input = page.locator('[data-composer-input]').first()
+    const input = page.locator('[data-composer-input][contenteditable="true"]').first()
     let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
     for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
-      await input.fill(`/permission ${preset}`)
+      await writeComposerDraft(page, input, `/permission ${preset}`)
       await input.press('Enter')
       await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
         .waitFor({ timeout: 10_000 })
 
       const settled = scaffold.whenTurnSettled()
-      await input.fill(PROMPTS[index] as string)
+      await writeComposerDraft(page, input, PROMPTS[index] as string)
       await input.press('Enter')
       sessionId = await settled
-      await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
+      await input.waitFor({ timeout: 10_000 })
     }
 
-    await input.fill('/permission read-only')
+    await writeComposerDraft(page, input, '/permission read-only')
     await input.press('Enter')
     await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled()
-    await input.fill(PROMPTS[3])
+    await writeComposerDraft(page, input, PROMPTS[3])
     await input.press('Enter')
     sessionId = await settled
 

+ 15 - 0
apps/web/tests/question-composer.e2e.ts

@@ -195,6 +195,21 @@ describe('web e2e: resident question composer round trip', () => {
     expect(await blue.getAttribute('aria-checked')).toBe('true')
     expect(await custom.inputValue()).toBe('Include accessibility notes')
     if (MODE !== 'record') {
+      // A strict Session-slot switch remounts the composer. Open a fresh blank
+      // Session, then return to the still-waiting request and require its
+      // Session-scoped store to restore both option and free-text drafts.
+      const originalRow = page.locator('[role="treeitem"]')
+        .filter({ hasText: 'Use the ask_user_question tool' }).first()
+      await page.getByRole('button', { name: 'New session', exact: true }).last().click()
+      await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
+      await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
+      await originalRow.click()
+      await composer.waitFor({ timeout: 15_000 })
+      expect(await blue.getAttribute('aria-checked')).toBe('true')
+      expect(await custom.inputValue()).toBe('Include accessibility notes')
+
+      // This golden now owns the composed state after a real A -> B -> A
+      // Session cycle, not merely the state before the remount.
       const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
     }

+ 3 - 3
apps/web/tests/subagent-conversation.e2e.ts

@@ -267,7 +267,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
 
   it('keeps known descendants reachable across a stale empty catalog response', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
-    const pattern = '**/api/subagent.list'
+    const pattern = '**/api/subagents/list'
     let firstClaimed = false
     let emptyDelivered = false
     let trailingRequested = false
@@ -373,7 +373,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
 
   it('keeps a restored child neutral until its parent availability arrives', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
-    const pattern = '**/api/subagent.list'
+    const pattern = '**/api/subagents/list'
     let requested = false
     let releaseCatalog = (): void => {}
     const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
@@ -546,7 +546,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     const input = page.locator('[data-composer-input][contenteditable="true"]').first()
     await input.waitFor()
     const promptResponse = page.waitForResponse(response =>
-      new URL(response.url()).pathname === '/api/subagent.prompt')
+      new URL(response.url()).pathname === '/api/subagents/prompt')
     await input.fill(POST_FORK_FOLLOWUP)
     await input.press('Enter')
     const promptReceipt = await (await promptResponse).json() as {

+ 6 - 6
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -1,7 +1,7 @@
 // Web e2e scenario: the composer's independent Stop interrupts a running
 // continuable child. The child holds its model turn open through a replay
 // hang entry; the browser proves Send and Stop coexist, the parent-offline
-// disabled-Send-with-Stop composer, the subagent.interrupt
+// disabled-Send-with-Stop composer, the subagents/interruptByParent
 // (never session.cancel) transport, the parked follow-up, and the FIFO resume
 // on a waking send.
 //
@@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     // parentAvailable: false while the child Activation stays live (the
     // interrupt RPC itself needs no live parent — covered host-side by
     // subagent-interrupt.e2e.ts).
-    const pattern = '**/api/subagent.list'
+    const pattern = '**/api/subagents/list'
     await page.route(pattern, async (route) => {
       const response = await route.fetch()
       const body = await response.json() as {
@@ -228,7 +228,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       )
       const aborted = waitForAbortedTurn(scaffold, childId)
       const interruptResponse = page.waitForResponse(response =>
-        new URL(response.url()).pathname === '/api/subagent.interrupt')
+        new URL(response.url()).pathname === '/api/subagents/interruptByParent')
       await stop.click()
       expect(((await (await interruptResponse).json()) as {
         result: { ok: boolean; value?: { accepted: boolean } }
@@ -252,7 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     }
   }, 60_000)
 
-  it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
+  it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
     // Reselect the child with the truthful catalog: parent available again.
     await page.getByRole('navigation', { name: 'Session hierarchy' })
@@ -265,7 +265,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
 
     // Queue a follow-up through Send while independent Stop remains available.
     const promptResponse = page.waitForResponse(response =>
-      new URL(response.url()).pathname === '/api/subagent.prompt')
+      new URL(response.url()).pathname === '/api/subagents/prompt')
     await input.fill(FOLLOWUP)
     await page.getByRole('button', { name: 'Send message' }).click()
     expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
@@ -275,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     const stop = page.getByRole('button', { name: 'Stop generating' })
     expect(await stop.count()).toBe(1)
     const interruptResponse = page.waitForResponse(response =>
-      new URL(response.url()).pathname === '/api/subagent.interrupt')
+      new URL(response.url()).pathname === '/api/subagents/interruptByParent')
     await stop.click()
     expect(((await (await interruptResponse).json()) as {
       result: { ok: boolean; value?: { accepted: boolean } }

+ 32 - 34
apps/web/tests/subagent-interrupt.e2e.ts

@@ -1,4 +1,4 @@
-// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
+// Web e2e scenario (browserless): the subagents interrupt Remote against the real
 // composition. A live continuable child holds its model turn open through a
 // replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
 // proves from the real session state that the turn aborted, the follow-up
@@ -22,25 +22,12 @@ const WAKING = 'And add one concrete example.'
 
 type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
 
-/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
-async function rpc<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<RpcResult<T>> {
-  const response = await scaffold.hostFetch(`/api/${method}`, {
-    method: 'POST',
-    headers: { 'content-type': 'application/json' },
-    body: JSON.stringify({
-      type: 'client-request',
-      rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
-      method,
-      payload,
-    }),
-  })
-  if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
-  return (await response.json() as { result: RpcResult<T> }).result
-}
-
-/** POST one generated Session Remote unary through the API Gateway carrier. */
-async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
-  const endpoint = `session/${method}`
+/** POST one generated Remote unary through the API Gateway carrier. */
+async function remote<T>(
+  scaffold: WebScaffold,
+  endpoint: string,
+  args: Readonly<Record<string, unknown>>,
+): Promise<RpcResult<T>> {
   const response = await scaffold.hostFetch(`/api/${endpoint}`, {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
@@ -48,13 +35,18 @@ async function sessionRemote<T>(scaffold: WebScaffold, method: string, request:
       type: 'client-request',
       rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`,
       method: endpoint,
-      payload: { args: { request } },
+      payload: { args },
     }),
   })
   if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
   return (await response.json() as { result: RpcResult<T> }).result
 }
 
+/** POST one generated Session Remote unary through the API Gateway carrier. */
+function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
+  return remote<T>(scaffold, `session/${method}`, { request })
+}
+
 /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
 async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
   const deadline = Date.now() + timeoutMs
@@ -78,7 +70,7 @@ function textCompletion(text: string): object {
   }
 }
 
-describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
+describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => {
   let scaffold: WebScaffold
   let sidecarRoot: string
   let readyFile: string
@@ -138,18 +130,21 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
 
   it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
     // Queue the follow-up while the turn is still open, then interrupt.
-    const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
-      parentSessionId: parentId,
-      childSessionId: childId,
-      mode: 'continuable',
-      content: [{ type: 'text', text: FOLLOWUP }],
+    const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
+      request: {
+        requestId: randomUUID(),
+        parentSessionId: parentId,
+        childSessionId: childId,
+        mode: 'continuable',
+        content: [{ type: 'text', text: FOLLOWUP }],
+      },
     })
     expect(queued).toMatchObject({ ok: true })
 
     const settled = scaffold.whenTurnSettled()
-    const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', {
-      parentSessionId: parentId,
+    const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', {
       childSessionId: childId,
+      parentSessionId: parentId,
       mode: 'continuable',
     })
     expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
@@ -169,11 +164,14 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
 
     // Only an explicit waking send resumes the parked queue, FIFO, then the
     // child runs both turns to completion and settles.
-    const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
-      parentSessionId: parentId,
-      childSessionId: childId,
-      mode: 'continuable',
-      content: [{ type: 'text', text: WAKING }],
+    const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
+      request: {
+        requestId: randomUUID(),
+        parentSessionId: parentId,
+        childSessionId: childId,
+        mode: 'continuable',
+        content: [{ type: 'text', text: WAKING }],
+      },
     })
     expect(waking).toMatchObject({ ok: true })
     await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()

+ 10 - 0
apps/web/tests/support.ts

@@ -117,6 +117,15 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
  * selection, and the batched edit lands on a null selection and is silently
  * dropped, leaving the previous draft in place. Real keystrokes leave room for
  * `selectionchange` between keys, which is also what a user's typing does.
+ *
+ * Waits for the surface to be editable first. While the input machine is
+ * adjudicating or submitting a send — and in every locked state (removed
+ * session, no workspace, an owner block) — the composer renders read-only
+ * with `contenteditable="false"` on the same element. `fill()` throws
+ * immediately on that element, and `isEnabled()` reports `true` for a
+ * `<div>` regardless of the attribute — so a gesture directly after a
+ * submit must gate on the attribute, not on enablement. A running turn by
+ * itself keeps the composer editable (that is what queueing types into).
  * @param page - the page under test.
  * @param input - the `[data-composer-input]` surface locator.
  * @param text - the replacement draft; `''` clears the draft. Must not
@@ -127,6 +136,7 @@ export async function writeComposerDraft(
   input: ReturnType<Page['locator']>,
   text: string,
 ): Promise<void> {
+  await input.and(page.locator('[contenteditable="true"]')).waitFor({ timeout: 15_000 })
   await input.click()
   await page.keyboard.press('ControlOrMeta+A')
   if (text === '') await page.keyboard.press('Backspace')

+ 26 - 6
apps/web/tests/trajectory-image-display.expected.e2e.ts

@@ -6,12 +6,24 @@
 // selecting the ledger record renders the shared ui-attachment gallery from
 // the durable session-log reference, and the browser URL is the SAME object
 // URL Chat resolved — one sessions.attachment read per session attachment.
-import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
 import { expect, it, vi } from 'vitest'
 import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
 
 installAssembledBootEnv()
 
+/**
+ * How long the mounted tree waits out the virtual ledger's scroll-idle timer.
+ * jsdom fires no `scrollend`, so `@tanstack/react-virtual` falls back to a
+ * debounce it re-arms on every scroll event (`isScrollingResetDelay`, 150ms by
+ * default) and its unsubscribe removes only the listeners; a scenario that
+ * ends inside that window leaves the timer to re-render the table after vitest
+ * has torn this file's jsdom down, where React reads a `window` that is gone.
+ * Armed later and with a longer delay than the debounce, this wait always
+ * expires after it.
+ */
+const SCROLL_IDLE_DRAIN_MS = 400
+
 /** Open the fixture history session and wait for the Chat gallery to load. */
 async function openFixtureSession(): Promise<void> {
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
@@ -41,16 +53,24 @@ async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
   }, { timeout: 10_000 })
   const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement
   if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing')
-  for (let top = 0; top <= 40_000; top += 1_000) {
+  const findRow = (): HTMLElement | undefined =>
+    [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
+      .find(row => row.textContent?.includes(needle))
+  let mounted = false
+  for (let top = 0; !mounted && top <= 40_000; top += 1_000) {
     pane.scrollTop = top
     fireEvent.scroll(pane)
     // Let the virtualizer publish the new window before probing.
     await new Promise(resolve => setTimeout(resolve, 25))
-    const hit = [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
-      .find(row => row.textContent?.includes(needle))
-    if (hit !== undefined) return hit
+    mounted = findRow() !== undefined
   }
-  throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
+  // Nothing scrolls the ledger after this, so draining the scroll-idle
+  // debounce here leaves no timer armed for the rest of the scenario. The
+  // drained reset re-renders the window, so the row is read afterwards.
+  await act(async () => { await new Promise(resolve => setTimeout(resolve, SCROLL_IDLE_DRAIN_MS)) })
+  const hit = findRow()
+  if (hit === undefined) throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
+  return hit
 }
 
 it('renders durable record images in the Trajectory details panel from the shared cache', async () => {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 74834994902bd5fe1e75653ab5645aef5bf587be
-config-catalog.zh.md: 77c83f46128a54b1a8f917e82ce3c6750060fcec
+config-catalog.md: f91a64d73af53cc9c57d9d2e795f8f54bcf9eacf
+config-catalog.zh.md: 8b1ab084668ef8e1568801855f9aebc19e76a5e3

+ 2 - 2
docs/config-catalog.md

@@ -774,7 +774,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
 
 ## `@deepseek-ai/dsh-host-apiproxy`
 
-Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController`
+Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController`
 
 ```ts config-catalog
 /** Gateway plugin configuration. */
@@ -1307,7 +1307,7 @@ export interface ReplayModelConfig {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

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

@@ -776,7 +776,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-host-apiproxy`
 
-需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController`
+需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController`
 
 ```ts config-catalog
 /** Gateway plugin configuration. */
@@ -1309,7 +1309,7 @@ export interface ReplayModelConfig {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
+来源:[`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: ff4a530f6071824da89c88a30a9744695f4d4eef
-event-producer-consumer.zh.md: 074597f038b215731e8c005afcd2fed1bc8c4bd4
+event-producer-consumer.md: bcf7ec4f3418d6b598b7edecfb2e4386f1a42e97
+event-producer-consumer.zh.md: beba6bee877989ca2fc3d33d26a989be30069c33

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

@@ -52,10 +52,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

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

@@ -54,10 +54,10 @@
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

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

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

+ 6 - 3
docs/module-graph.md

@@ -1066,6 +1066,7 @@ flowchart TD
   pkg_subagent --> pkg_session_query
   pkg_subagent --> pkg_system_prompt
   pkg_subagent --> pkg_tools
+  pkg_subagent --> pkg_typert_protocol
   pkg_subagent --> pkg_user_approval
   pkg_session_query_sqlite --> pkg_invariants
   pkg_session_query_sqlite --> pkg_session
@@ -1302,6 +1303,7 @@ flowchart TD
   pkg_api_remotes --> pkg_session
   pkg_api_remotes --> pkg_session_reference
   pkg_api_remotes --> pkg_settings
+  pkg_api_remotes --> pkg_subagent
   pkg_api_remotes --> pkg_user_approval
   pkg_api_remotes --> pkg_user_questions
   pkg_client_ui_session --> pkg_api_session_controller
@@ -1652,6 +1654,7 @@ flowchart TD
   pkg_client_test_runtime --> pkg_client_ui_slots
   pkg_client_test_runtime --> pkg_invariants
   pkg_client_test_runtime --> pkg_session
+  pkg_client_test_runtime --> pkg_subagent
   pkg_client_ui_skill --> pkg_api_remotes
   pkg_client_ui_skill --> pkg_api_session_controller
   pkg_client_ui_skill --> pkg_client_connection
@@ -1848,7 +1851,7 @@ flowchart TD
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
@@ -1879,7 +1882,7 @@ flowchart TD
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
+| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
@@ -1918,6 +1921,6 @@ flowchart TD
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
 | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
-| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 6 - 3
docs/module-graph.zh.md

@@ -1068,6 +1068,7 @@ flowchart TD
   pkg_subagent --> pkg_session_query
   pkg_subagent --> pkg_system_prompt
   pkg_subagent --> pkg_tools
+  pkg_subagent --> pkg_typert_protocol
   pkg_subagent --> pkg_user_approval
   pkg_session_query_sqlite --> pkg_invariants
   pkg_session_query_sqlite --> pkg_session
@@ -1304,6 +1305,7 @@ flowchart TD
   pkg_api_remotes --> pkg_session
   pkg_api_remotes --> pkg_session_reference
   pkg_api_remotes --> pkg_settings
+  pkg_api_remotes --> pkg_subagent
   pkg_api_remotes --> pkg_user_approval
   pkg_api_remotes --> pkg_user_questions
   pkg_client_ui_session --> pkg_api_session_controller
@@ -1654,6 +1656,7 @@ flowchart TD
   pkg_client_test_runtime --> pkg_client_ui_slots
   pkg_client_test_runtime --> pkg_invariants
   pkg_client_test_runtime --> pkg_session
+  pkg_client_test_runtime --> pkg_subagent
   pkg_client_ui_skill --> pkg_api_remotes
   pkg_client_ui_skill --> pkg_api_session_controller
   pkg_client_ui_skill --> pkg_client_connection
@@ -1850,7 +1853,7 @@ flowchart TD
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
-| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
@@ -1881,7 +1884,7 @@ flowchart TD
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
+| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
@@ -1920,6 +1923,6 @@ flowchart TD
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
 | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
-| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 2 - 2
docs/subsystems/client-modules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
-client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc
-client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72
+client-modules.md: 6188880fcd682c7c3c105764f212e92395444b61
+client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1

+ 2 - 6
docs/subsystems/client-modules.md

@@ -95,16 +95,12 @@ interface ClientArtifactBaseline {
   readonly mtimeMs: number
   /** Bundle size in bytes. */
   readonly size: number
-  /** Source-map modification time, or null when no map was observable. */
-  readonly mapMtimeMs: number | null
-  /** Source-map size in bytes, or null when no map was observable. */
-  readonly mapSize: number | null
 }
 ```
 
-`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
+`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
-In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
+In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 6
docs/subsystems/client-modules.zh.md

@@ -95,16 +95,12 @@ interface ClientArtifactBaseline {
   readonly mtimeMs: number
   /** Bundle size in bytes. */
   readonly size: number
-  /** Source-map modification time, or null when no map was observable. */
-  readonly mapMtimeMs: number | null
-  /** Source-map size in bytes, or null when no map was observable. */
-  readonly mapSize: number | null
 }
 ```
 
-`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
+`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
+开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: 402836cb727fb99d92cea5e2a0d242b010aad2c9
-persistence.zh.md: 424fc928d5a8ef18b403ea31e5a5d26d3fd3fdc7
+persistence.md: f73b9ab01c232c4d4fec5aa51e5250c60b9337da
+persistence.zh.md: 061c29f6b54c41137e3c764e9a7804f411f63f17

+ 1 - 1
docs/subsystems/persistence.md

@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
 All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
 
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 18 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 19 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 1 - 1
docs/subsystems/persistence.zh.md

@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件:
 
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 18 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 19 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
docs/subsystems/slots.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/slots.md
-slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee
-slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0
+slots.md: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73
+slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a

+ 2 - 0
docs/subsystems/slots.md

@@ -123,6 +123,8 @@ root
 │     ├─ settings.onboarding
 │     └─ settings.section
 │        ├─ settings.general.item
+│        ├─ settings.models.provider-card
+│        ├─ settings.models.footer
 │        └─ settings.plugins.tab
 │           └─ settings.plugin.item
 ├─ conversation

+ 2 - 0
docs/subsystems/slots.zh.md

@@ -123,6 +123,8 @@ root
 │     ├─ settings.onboarding
 │     └─ settings.section
 │        ├─ settings.general.item
+│        ├─ settings.models.provider-card
+│        ├─ settings.models.footer
 │        └─ settings.plugins.tab
 │           └─ settings.plugin.item
 ├─ conversation

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: e06d721427a195519817e4cfcf7c2bb38f572970
-subagent.zh.md: 0364360c783c1de1f6303b1a9cbc431f14db6dc2
+subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f
+subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468

+ 47 - 0
docs/subsystems/subagent.md

@@ -648,6 +648,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
  */
 listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
 
+/**
+ * Remote face of {@link listChildren} for one browser: the durable listing
+ * plus live Agent activity and the delivery-time parent availability hint.
+ * Parent availability is a hint; {@link prompt} performs the authoritative
+ * check. Named apart from the provider-name {@link list}, which owns the
+ * member.
+ * @param parentSessionId - parent session whose direct children are listed.
+ * @param signal - carrier cancellation forwarded to Session queries.
+ * @returns the catalog view for that parent.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
+ *   `cancelled` for an aborted read, `subagent-projections-unavailable` when
+ *   the deployment has no projection registry, otherwise `internal`.
+ */
+@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
+
+/**
+ * Deliver one browser-authored message to a continuable child through the
+ * exact live direct parent, retaining the caller-minted request identity and
+ * validated browser zone on the accepted message. Success identifies the
+ * message the child's FIFO inbox accepted; later execution is independent of
+ * this call.
+ * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param signal - carrier cancellation, owning the call until inbox acceptance.
+ * @returns the accepted message's inbox identity.
+ * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
+ *   `subagent-parent-unavailable`, `subagent-not-resumable`,
+ *   `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
+ *   `internal`.
+ */
+@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>
+
+/**
+ * Remote face of {@link interrupt} under one durable parent address. No
+ * catalog, history, persistence, or parent Agent lookup runs: the core
+ * primitive alone authorizes the address against the live Activation, which
+ * is what keeps a live child interruptible while its parent Agent is offline.
+ * Absent, idle, and already-completed targets are accepted no-ops there.
+ * @param childSessionId - durable child session id to interrupt.
+ * @param parentSessionId - durable direct parent whose authority is claimed.
+ * @param mode - required continuable-address discriminator.
+ * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty id,
+ *   `subagent-unauthorized` when the address does not own the live target,
+ *   otherwise `internal`.
+ */
+@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt
+
 /**
  * Register a provider under its name. Registration is effect-scoped and HMR
  * safe; removing a provider blocks new starts but does not revoke runs that

+ 47 - 0
docs/subsystems/subagent.zh.md

@@ -652,6 +652,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
  */
 listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
 
+/**
+ * Remote face of {@link listChildren} for one browser: the durable listing
+ * plus live Agent activity and the delivery-time parent availability hint.
+ * Parent availability is a hint; {@link prompt} performs the authoritative
+ * check. Named apart from the provider-name {@link list}, which owns the
+ * member.
+ * @param parentSessionId - parent session whose direct children are listed.
+ * @param signal - carrier cancellation forwarded to Session queries.
+ * @returns the catalog view for that parent.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
+ *   `cancelled` for an aborted read, `subagent-projections-unavailable` when
+ *   the deployment has no projection registry, otherwise `internal`.
+ */
+@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
+
+/**
+ * Deliver one browser-authored message to a continuable child through the
+ * exact live direct parent, retaining the caller-minted request identity and
+ * validated browser zone on the accepted message. Success identifies the
+ * message the child's FIFO inbox accepted; later execution is independent of
+ * this call.
+ * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param signal - carrier cancellation, owning the call until inbox acceptance.
+ * @returns the accepted message's inbox identity.
+ * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
+ *   `subagent-parent-unavailable`, `subagent-not-resumable`,
+ *   `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
+ *   `internal`.
+ */
+@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>
+
+/**
+ * Remote face of {@link interrupt} under one durable parent address. No
+ * catalog, history, persistence, or parent Agent lookup runs: the core
+ * primitive alone authorizes the address against the live Activation, which
+ * is what keeps a live child interruptible while its parent Agent is offline.
+ * Absent, idle, and already-completed targets are accepted no-ops there.
+ * @param childSessionId - durable child session id to interrupt.
+ * @param parentSessionId - durable direct parent whose authority is claimed.
+ * @param mode - required continuable-address discriminator.
+ * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
+ * @throws {TypertRemoteFailure} `bad-request` for an empty id,
+ *   `subagent-unauthorized` when the address does not own the live target,
+ *   otherwise `internal`.
+ */
+@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt
+
 /**
  * Register a provider under its name. Registration is effect-scoped and HMR
  * safe; removing a provider blocks new starts but does not revoke runs that

+ 2 - 2
package.json

@@ -94,8 +94,8 @@
     "gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
     "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
     "docs:dev": "pnpm --filter @deepseek-ai/website run dev",
-    "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments",
-    "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments",
+    "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
+    "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
     "docs:preview": "pnpm --filter @deepseek-ai/website run preview",
     "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build",
     "website:dev": "pnpm run docs:dev",

+ 2 - 0
packages/api/remotes/package.json

@@ -76,6 +76,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^"
   },
@@ -97,6 +98,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^"
   }

+ 5 - 2
packages/api/remotes/src/client/index.ts

@@ -9,6 +9,7 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
+import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
 import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
 import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
@@ -22,6 +23,8 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
+export type {} from '@deepseek-ai/dsh-subagent/remote'
+export type * from '@deepseek-ai/dsh-subagent/client'
 export type {} from '@deepseek-ai/dsh-api-session-controller/remote'
 export type * from '@deepseek-ai/dsh-api-session-controller/types'
 export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
@@ -54,7 +57,6 @@ export type {
   MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
   RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
   SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
-  SubagentAddress, SubagentCatalog,
 } from '@deepseek-ai/dsh-client-connection/client'
 export type {} from '@deepseek-ai/dsh-api-gateway/client'
 export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -110,6 +112,7 @@ export type ClientFailure =
   | import('@deepseek-ai/dsh-client-connection/client').RpcError
   | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
   | import('@deepseek-ai/dsh-api-session-controller/types').SessionError
+  | import('@deepseek-ai/dsh-subagent/client').SubagentControlError
   | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
 
 /** Success or failure returned by Client operations spanning both API families. */
@@ -138,7 +141,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
-      sessionRemote, workspaceRemote,
+      subagentsRemote, sessionRemote, workspaceRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
     }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -54,6 +54,9 @@
     {
       "path": "../../settings/settings"
     },
+    {
+      "path": "../../subagent/subagent"
+    },
     {
       "path": "../../interaction/user-approval"
     },

+ 4 - 3
packages/api/session-controller/src/client/contract/result.ts

@@ -1,10 +1,11 @@
-/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */
+/** Client operation results spanning the Session and subagent Remote calls. */
 
 import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionError } from '../../types.ts'
 
 /** Failure surfaced by the Client Session object layer. */
-export type ClientFailure = RpcError | SessionError
+export type ClientFailure = RpcError | SessionError | SubagentControlError
 
 /** Success or failure returned by a Client Session operation. */
 export type ClientResult<T> =
@@ -13,7 +14,7 @@ export type ClientResult<T> =
 
 /**
  * Fold a rejected carrier operation into the Client Session failure vocabulary.
- * @param error - rejection from a legacy subagent or local carrier call.
+ * @param error - rejection from a Remote or local carrier call.
  * @returns the failure branch of a Client Session result.
  */
 export function transportResult<T>(error: unknown): ClientResult<T> {

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

@@ -5,7 +5,7 @@
  * explicit act of widening what features may do to the sessions domain.
  */
 import type { Context } from '@deepseek-ai/cordis'
-import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { AgentContext } from '../scope.ts'

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

@@ -2,7 +2,7 @@
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { ClientFailure } from './result.ts'
 
 /** One transient inbox occurrence from the authoritative queue snapshot. */

+ 2 - 1
packages/api/session-controller/src/client/index.ts

@@ -73,6 +73,7 @@ export const inject = [
   'remote',
   'remote.commands',
   'remote.session',
+  'remote.subagents',
 ]
 
 /**
@@ -82,7 +83,7 @@ export const inject = [
 export function apply(ctx: Context): void {
   const connection = ctx.get('connection') as ConnectionHandle
   const remotes = ctx.remote as unknown as SessionRemotes
-  const sessions = new ClientSessions(ctx, connection.api, remotes)
+  const sessions = new ClientSessions(ctx, remotes)
   ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
   ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
   ctx.remote.$on('api-session/status', (sessionId, running) => {

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

@@ -2,9 +2,7 @@
 // dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
 // List data never enters zustand; React connects via subscribe/getListSnapshot.
 
-import type {
-  IApiClient, SubagentAddress, SubagentCatalog,
-} from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type {
@@ -145,11 +143,10 @@ export class SessionManager {
   })
 
   /**
-   * @param api - shared wire client.
+   * @param remote - generated Remote namespaces the Session cluster calls.
    * @param restoredSelection - persisted real-Session selection candidate.
    */
   constructor(
-    private readonly api: IApiClient,
     private readonly remote: SessionRemotes,
     restoredSelection?: SessionId,
     restoredAddress?: SubagentAddress,
@@ -321,7 +318,7 @@ export class SessionManager {
     const parentAvailable = address === undefined
       ? undefined
       : this.catalogs.get(address.parentSessionId)?.parentAvailable
-    return new Session(sessionId, this.api, this.remote, {
+    return new Session(sessionId, this.remote, {
       ...(address === undefined ? {} : {
         address,
         ...catalogAvailability(parentAvailable),
@@ -369,7 +366,7 @@ export class SessionManager {
     this.notifier.markDirty()
     const operation = (async () => {
       try {
-        const { result } = await this.api.subagents.list({ parentSessionId })
+        const result = toSessionResult(await this.remote.subagents.list(parentSessionId))
         if (result.ok) {
           const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
             ?? result.value.parentAvailable

+ 18 - 0
packages/api/session-controller/src/client/sessions/remotes.ts

@@ -8,6 +8,9 @@
 import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {
+  SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
+} from '@deepseek-ai/dsh-subagent/client'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRemote } from '../transport.ts'
 
@@ -21,9 +24,24 @@ export interface SessionCommandsRemote {
   ): Promise<RemoteResult<object | undefined>>
 }
 
+/** Narrow subagent namespace consumed by a Client Session and its manager. */
+export interface SessionSubagentsRemote {
+  list(parentSessionId: SessionId, signal?: AbortSignal): Promise<RemoteResult<SubagentCatalog>>
+  prompt(
+    request: SubagentPromptRequest,
+    signal?: AbortSignal,
+  ): Promise<RemoteResult<SubagentPromptReceipt>>
+  interruptByParent(
+    childSessionId: SessionId,
+    parentSessionId: SessionId,
+    mode: 'continuable',
+  ): Promise<RemoteResult<SubagentInterruptReceipt>>
+}
+
 /** Generated Remote namespaces consumed by the Client Session object layer. */
 export interface SessionRemotes {
   readonly $stream: ClientRemote['$stream']
   readonly commands: SessionCommandsRemote
   readonly session: SessionRemote
+  readonly subagents: SessionSubagentsRemote
 }

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

@@ -15,9 +15,7 @@
  * survives frozen (read-only view) until the stage moves on.
  */
 import type { Context, Fiber } from '@deepseek-ai/cordis'
-import type {
-  IApiClient, SubagentAddress,
-} from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -218,12 +216,10 @@ export class ClientSessions implements ISessions {
 
   /**
    * @param ctx - client root context (scope fibers mount under it).
-   * @param api - wire client shared with every Session.
    * @param remote - generated Remote namespaces shared with every Session.
    */
   constructor(
     private readonly rootCtx: Context,
-    api: IApiClient,
     remote: SessionRemotes,
   ) {
     this.selection = createSnapshotStore<SessionSelection>(
@@ -231,7 +227,6 @@ export class ClientSessions implements ISessions {
       { persist: { name: 'dsh.sessions.current' } })
     const restored = this.selection.getSnapshot()
     this.manager = new SessionManager(
-      api,
       remote,
       restored.sessionId,
       restored.subagentAddress,

+ 13 - 10
packages/api/session-controller/src/client/sessions/session.ts

@@ -3,9 +3,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type {
-  IApiClient, SubagentAddress,
-} from '@deepseek-ai/dsh-client-connection/client'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
@@ -133,13 +131,11 @@ export class Session implements SessionFace {
 
   /**
    * @param sessionId - Host session identity (client sessions are always Host-born).
-   * @param api - shared wire client.
    * @param remote - generated Remote namespaces this session calls.
    * @param options - optional manager-owned state observers.
    */
   constructor(
     readonly sessionId: SessionId,
-    private readonly api: IApiClient,
     private readonly remote: SessionRemotes,
     private readonly options: SessionOptions = {},
   ) {
@@ -222,13 +218,16 @@ export class Session implements SessionFace {
             },
           }
         } else {
-          const routed = (await this.api.subagents.prompt({
-            ...this.address,
+          const routed = toSessionResult(await this.remote.subagents.prompt({
+            requestId: randomUUID() as SessionRequestId,
+            parentSessionId: this.address.parentSessionId,
+            childSessionId: this.address.childSessionId,
+            mode: this.address.mode,
             content: content.flatMap(part => part.type === 'text'
               ? [{ type: 'text' as const, text: part.text }]
               : []),
             clientTimeZone: resolvedClientTimeZone(),
-          }, signal)).result
+          }, signal))
           result = routed.ok ? { ok: true, value: { accepted: true } } : routed
         }
       }
@@ -290,7 +289,7 @@ export class Session implements SessionFace {
   /**
    * Stop the active turn while the Host preserves pending inbox work; failures
    * land in promptError (same error-strip display slot). A continuable
-   * subagent address routes through `subagent.interrupt`, whose durable
+   * subagent address routes through `subagents.interruptByParent`, whose durable
    * parent-address authority works without a live parent Agent; a one-shot
    * address stays uncancellable (the UI offers no stop action, so this arm is
    * defensive).
@@ -314,7 +313,11 @@ export class Session implements SessionFace {
     let result: ClientResult<{ accepted: true }>
     try {
       result = address !== undefined
-        ? (await this.api.subagents.interrupt(address)).result
+        ? toSessionResult(await this.remote.subagents.interruptByParent(
+          address.childSessionId,
+          address.parentSessionId,
+          address.mode,
+        ))
         : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
     } catch (error) {
       result = transportResult(error)

+ 1 - 0
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -85,6 +85,7 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
   })
   ctx.reflect.provide('remote.commands', remote.commands)
   ctx.reflect.provide('remote.session', remote.session)
+  ctx.reflect.provide('remote.subagents', remote.subagents)
   const fiber = ctx.plugin(SessionClient)
   await fiber
   const sessions = ctx.sessions as ClientSessions

+ 33 - 15
packages/api/session-controller/tests/fake-api.client.ts

@@ -2,8 +2,9 @@
 // data source on a real clock; behavior tests need per-case responses and
 // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
 import type {
-  IApiClient,
+  IApiClient, MessageId,
   RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry,
+  SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
   WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type {
@@ -20,7 +21,7 @@ import type {
 } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
-import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteStream,
   RemoteStreamError,
@@ -84,10 +85,20 @@ export function err<T>(error: RpcError): RpcResponse<T> {
 }
 
 /** Successful generated Remote result for programmable domain fakes. */
-function remoteOk<T>(value: T): RemoteResult<T> {
+export function remoteOk<T>(value: T): RemoteResult<T> {
   return { ok: true, value }
 }
 
+/**
+ * Failed generated Remote result carrying an owner's own failure vocabulary,
+ * which the carrier's closed RPC code set does not contain.
+ * @param error - the owner-declared failure.
+ * @returns the failure branch of a Remote result.
+ */
+export function remoteErr<T>(error: RemoteFailure): RemoteResult<T> {
+  return { ok: false, error }
+}
+
 type ValueStreamItem<F> =
   | { kind: 'frame'; value: F; delivered?: () => void }
   | { kind: 'end' }
@@ -189,19 +200,13 @@ export class FakeApiClient implements IApiClient {
   }
   lastSearchSignal: AbortSignal | undefined
 
-  onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
-    = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
-  onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
-    = () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
+  onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
+    = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
+  onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
+    = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId }))
 
-  onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
-    = () => Promise.resolve(ok({ accepted: true as const }))
-
-  readonly subagents: IApiClient['subagents'] = {
-    list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
-    prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
-    interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
-  }
+  onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
+    = () => Promise.resolve(remoteOk({ accepted: true as const }))
 
   readonly host: IApiClient['host'] = {
     describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
@@ -301,6 +306,19 @@ export class FakeApiClient implements IApiClient {
         follow: (request, signal) => this.openFollow(request, signal),
         control: signal => this.openControl(signal),
       },
+      subagents: {
+        list: parentSessionId => this.record(
+          'subagents.list',
+          parentSessionId,
+          this.onSubagentList(parentSessionId),
+        ),
+        prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)),
+        interruptByParent: (childSessionId, parentSessionId, mode) => this.record(
+          'subagents.interruptByParent',
+          { childSessionId, parentSessionId, mode },
+          this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }),
+        ),
+      },
       workspace: {
         create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
         rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),

+ 73 - 77
packages/api/session-controller/tests/manager.client.spec.ts

@@ -8,7 +8,7 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import type {} from '@deepseek-ai/dsh-session-title/client'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
+import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
 import { entries, plainTurn } from './event-script.client.ts'
 
 const S1 = 'fk-m1' as SessionId
@@ -29,14 +29,14 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) {
 
 function makeManager(): SessionManager {
   const api = new FakeApiClient()
-  return new SessionManager(api, fakeRemote(api))
+  return new SessionManager(fakeRemote(api))
 }
 
 describe('SessionManager instances', () => {
   it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     const session = manager.get(S1)
     expect(manager.get(S1)).toBe(session) // resident: same instance forever
@@ -50,7 +50,7 @@ describe('list lifecycle', () => {
     const api = new FakeApiClient()
     const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
     api.onList = () => gate.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const first = manager.refreshList()
     const second = manager.refreshList()
     expect(manager.getListSnapshot().state).toBe('loading')
@@ -66,7 +66,7 @@ describe('list lifecycle', () => {
     const api = new FakeApiClient()
     const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
     api.onList = () => first.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const hydration = manager.refreshList()
     manager.handleSessionAdded(summary(S2, { blank: true }))
     first.resolve(ok({ items: [summary(S1)] as never[] }))
@@ -83,7 +83,7 @@ describe('list lifecycle', () => {
   it('advances list activity from the filtered Host notification', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
 
     manager.handleSessionActivity(S1, 500)
@@ -93,7 +93,7 @@ describe('list lifecycle', () => {
   it('keeps the error in the list snapshot on failure', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
     // A failed pull does not step the arrival phase: still pending.
@@ -102,7 +102,7 @@ describe('list lifecycle', () => {
 
   it('phase steps pending → ready on the first successful pull and never returns', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     expect(manager.getListSnapshot().phase).toBe('pending')
     await manager.refreshList()
     expect(manager.getListSnapshot().phase).toBe('ready')
@@ -121,7 +121,7 @@ describe('list lifecycle', () => {
   it('merges create into the list immediately without waiting for a refresh', async () => {
     const api = new FakeApiClient()
     api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const result = await manager.create()
     expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
     expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
@@ -129,7 +129,7 @@ describe('list lifecycle', () => {
 
   it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const titleFrame = (title: string, seq: number) => {
       manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
     }
@@ -153,7 +153,7 @@ describe('list lifecycle', () => {
 
   it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     // A push frame landed before the list (S2's title is newer than the block's cut).
     manager.handleControlFrame({
       type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
@@ -175,7 +175,7 @@ describe('list lifecycle', () => {
   it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
     frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
@@ -213,7 +213,7 @@ describe('search', () => {
       items: [{ sessionId: S1, snippet: 'matching excerpt' }],
       hasMore: true,
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const signal = new AbortController().signal
 
     await expect(manager.search('exact phrase', signal)).resolves.toEqual({
@@ -229,7 +229,7 @@ describe('search', () => {
 
   it('preserves business errors and folds transport failures', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     api.onSearch = () => Promise.resolve(err({
       code: 'internal',
       message: 'index unavailable',
@@ -252,7 +252,7 @@ describe('search', () => {
 describe('Host Remote event routing', () => {
   it('adds/removes/flips sessions and keeps removed instances resident', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     manager.handleSessionAdded(summary(S1, { blank: true }))
     manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
     expect(manager.getListSnapshot().items).toHaveLength(1)
@@ -279,14 +279,14 @@ describe('subagent catalogs', () => {
       summary(S1),
       summary(S2, { parentSessionId: S1, origin: 'subagent' }),
     ] as never[] }))
-    api.onSubagentList = () => Promise.resolve(ok({
+    api.onSubagentList = () => Promise.resolve(remoteOk({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     await manager.refreshSubagents(S1)
     manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
@@ -319,21 +319,23 @@ describe('subagent catalogs', () => {
       },
     ])
     expect(api.callsOf('subagent.history')).toEqual([])
-    expect(api.callsOf('subagent.prompt')).toEqual([
+    expect(api.callsOf('subagents.prompt')).toEqual([
       {
-        parentSessionId: S1, childSessionId: S2, mode: 'continuable',
+        requestId: expect.any(String) as unknown as string,
+        parentSessionId: S1, childSessionId: S2,
+        mode: 'continuable',
         content: [{ type: 'text', text: 'continue' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
     ])
     expect(api.callsOf('session.history')).toEqual([])
     expect(api.callsOf('session.prompt')).toEqual([])
-    const listCalls = api.callsOf('subagent.list').length
+    const listCalls = api.callsOf('subagents.list').length
     manager.handleSessionStatus(S2, false)
     expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
       kind: 'child', id: S2, activity: 'inactive',
     })
-    expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
+    expect(api.callsOf('subagents.list')).toHaveLength(listCalls)
 
     manager.handleSessionRemoved(S2)
     expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
@@ -351,20 +353,20 @@ describe('subagent catalogs', () => {
     vi.useFakeTimers()
     try {
       const api = new FakeApiClient()
-      const manager = new SessionManager(api, fakeRemote(api))
+      const manager = new SessionManager(fakeRemote(api))
       await manager.refreshSubagents(S1)
       manager.setSubagentCatalogOpen(S1, true)
       await Promise.resolve()
-      const baseline = api.callsOf('subagent.list').length
+      const baseline = api.callsOf('subagents.list').length
       manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
       manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
       await vi.advanceTimersByTimeAsync(50)
-      expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
+      expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
 
       manager.setSubagentCatalogOpen(S1, false)
       manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
       await vi.advanceTimersByTimeAsync(50)
-      expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
+      expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
     } finally {
       vi.useRealTimers()
     }
@@ -373,7 +375,7 @@ describe('subagent catalogs', () => {
   it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
     const api = new FakeApiClient()
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(ok({
+    api.onSubagentList = () => Promise.resolve(remoteOk({
       entries: [
         {
           kind: 'child', id: S1, mode: 'continuable', label: 'parent',
@@ -386,7 +388,7 @@ describe('subagent catalogs', () => {
       ] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshSubagents(root)
 
     manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
@@ -405,13 +407,13 @@ describe('subagent catalogs', () => {
     const root = 'fk-root' as SessionId
     const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => response.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshSubagents(root)
 
     manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
       parentSessionId: S1, origin: 'subagent',
     }))
-    response.resolve(ok({
+    response.resolve(remoteOk({
       entries: [{
         kind: 'child', id: S1, mode: 'continuable', label: 'parent',
         activity: 'inactive', hasChildren: false,
@@ -424,7 +426,7 @@ describe('subagent catalogs', () => {
       { kind: 'child', id: S1, hasChildren: true },
     ])
 
-    api.onSubagentList = () => Promise.resolve(ok({
+    api.onSubagentList = () => Promise.resolve(remoteOk({
       entries: [{
         kind: 'child', id: S1, mode: 'continuable', label: 'parent',
         activity: 'inactive', hasChildren: false,
@@ -442,12 +444,12 @@ describe('subagent catalogs', () => {
     const root = 'fk-root' as SessionId
     const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => response.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshSubagents(root)
 
     manager.handleSessionStatus(S1, false)
     manager.handleSessionStatus(S2, true)
-    response.resolve(ok({
+    response.resolve(remoteOk({
       entries: [
         {
           kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
@@ -470,14 +472,14 @@ describe('subagent catalogs', () => {
 
   it('marks a detached catalog child inactive without requiring a selected address', async () => {
     const api = new FakeApiClient()
-    api.onSubagentList = () => Promise.resolve(ok({
+    api.onSubagentList = () => Promise.resolve(remoteOk({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshSubagents(S1)
 
     manager.handleSessionRemoved(S2)
@@ -492,15 +494,15 @@ describe('subagent catalogs', () => {
     const root = 'fk-root' as SessionId
     const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => first.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
 
     const refresh = manager.refreshSubagents(root)
     expect(manager.refreshSubagents(root)).toBe(refresh)
-    api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
-    first.resolve(ok({ entries: [], parentAvailable: true }))
+    api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
+    first.resolve(remoteOk({ entries: [], parentAvailable: true }))
     await refresh
 
-    expect(api.callsOf('subagent.list')).toHaveLength(1)
+    expect(api.callsOf('subagents.list')).toHaveLength(1)
   })
 
   it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
@@ -511,7 +513,7 @@ describe('subagent catalogs', () => {
       const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
       const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
       api.onSubagentList = () => first.promise
-      const manager = new SessionManager(api, fakeRemote(api), root)
+      const manager = new SessionManager(fakeRemote(api), root)
       const refresh = manager.refreshSubagents(root)
       manager.setSubagentCatalogOpen(root, true)
 
@@ -522,7 +524,7 @@ describe('subagent catalogs', () => {
       manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
       await vi.advanceTimersByTimeAsync(50)
       api.onSubagentList = () => second.promise
-      first.resolve(ok({
+      first.resolve(remoteOk({
         entries: [{
           kind: 'child', id: S1, mode: 'continuable', label: 'older',
           activity: 'inactive', hasChildren: false,
@@ -531,7 +533,7 @@ describe('subagent catalogs', () => {
       }))
       await refresh
       // The trailing pull is already in flight (kicked synchronously in finally).
-      second.resolve(ok({
+      second.resolve(remoteOk({
         entries: [
           {
             kind: 'child', id: S1, mode: 'continuable', label: 'older',
@@ -545,8 +547,10 @@ describe('subagent catalogs', () => {
         parentAvailable: true,
       }))
       await second.promise
+      // The Remote face resolves one microtask after the response settles.
+      await vi.advanceTimersByTimeAsync(0)
 
-      expect(api.callsOf('subagent.list')).toHaveLength(2)
+      expect(api.callsOf('subagents.list')).toHaveLength(2)
       expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
         { kind: 'child', id: S1, label: 'older' },
         { kind: 'child', id: S2, label: 'new child' },
@@ -565,9 +569,9 @@ describe('subagent catalogs', () => {
     })
     const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => first.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshSubagents(root)
-    first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
+    first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
     await refresh
     manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
 
@@ -579,12 +583,12 @@ describe('subagent catalogs', () => {
     manager.handleSessionRemoved(root)
     const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => trailing.promise
-    mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
+    mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
     await midRefresh
     expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
 
-    trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
+    trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} }))
     await vi.waitFor(() => {
       expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
         state: 'error',
@@ -592,8 +596,7 @@ describe('subagent catalogs', () => {
       })
     })
 
-    const rootCalls = api.callsOf('subagent.list')
-      .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
+    const rootCalls = api.callsOf('subagents.list').filter(call => call === root)
     expect(rootCalls).toHaveLength(3)
     expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
@@ -602,14 +605,14 @@ describe('subagent catalogs', () => {
   it('invalidates catalog availability when the owning parent is removed', async () => {
     const api = new FakeApiClient()
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(ok({
+    api.onSubagentList = () => Promise.resolve(remoteOk({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'inactive', hasChildren: false,
       }] as never[],
       parentAvailable: true,
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshSubagents(root)
     manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
@@ -625,14 +628,14 @@ describe('remaining branches', () => {
   it('refreshList folds a transport throw into the error state', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.reject(new Error('list wire down'))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
   })
 
   it('refreshList pushes running bits down to already-instantiated sessions', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const session = manager.get(S1)
     api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
     await manager.refreshList()
@@ -642,7 +645,7 @@ describe('remaining branches', () => {
   it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
     const api = new FakeApiClient()
     api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.create({ cwd: '/tmp/w', sessionId: S1 })
     expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
     expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
@@ -662,7 +665,7 @@ describe('remaining branches', () => {
       message: 'published but unattached',
       details: { sessionId: S1, workspaceId: 'w1' },
     } as never))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
     expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
@@ -676,7 +679,7 @@ describe('remaining branches', () => {
       message: 'forked but unattached',
       details: { sessionId: S2, workspaceId: 'w1' },
     } as never))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const result = await manager.fork({ sessionId: S1 })
     expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
@@ -689,7 +692,7 @@ describe('remaining branches', () => {
   it('reconciles a preallocated id after an ordinary transport failure', async () => {
     const api = new FakeApiClient()
     api.onCreate = () => Promise.reject(new Error('response lost'))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
     expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
     expect(manager.getListSnapshot().items).toEqual([])
@@ -704,7 +707,7 @@ describe('remaining branches', () => {
 
   it('subscribe notifies on list changes and stops after unsubscribe', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     let notified = 0
     const unsubscribe = manager.subscribe(() => { notified++ })
     await manager.refreshList()
@@ -719,7 +722,7 @@ describe('remaining branches', () => {
 
   it('ignores Host status and error events for sessions without an instance', () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     manager.handleSessionStatus(S2, true)
     manager.handleSessionError(S2, '无实例')
   })
@@ -727,7 +730,7 @@ describe('remaining branches', () => {
   it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     const before = manager.getListSnapshot()
     manager.handleSessionStatus(S2, true)
@@ -743,7 +746,7 @@ describe('remaining branches', () => {
 
   it('carries parentSessionId from the added event into the lineage row', () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     manager.handleSessionAdded(summary(S1, { blank: true }))
     manager.handleSessionAdded(summary(S2, {
       blank: true, parentSessionId: S1, origin: 'subagent',
@@ -763,7 +766,7 @@ describe('connected generation', () => {
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
     }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const openedSession = manager.get(S1)
     await openedSession.open()
     manager.get(S2) // instantiated but never opened
@@ -782,26 +785,19 @@ describe('connected generation', () => {
     }
     const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
-    api.onSubagentList = payload => (
-      (payload as { parentSessionId: SessionId }).parentSessionId === S1
-        ? parent.promise
-        : child.promise
-    )
-    const manager = new SessionManager(api, fakeRemote(api), S2, address)
+    api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise)
+    const manager = new SessionManager(fakeRemote(api), S2, address)
 
     manager.handleConnected()
     expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
-    parent.resolve(ok({ entries: [], parentAvailable: true }))
-    child.resolve(ok({ entries: [], parentAvailable: true }))
+    parent.resolve(remoteOk({ entries: [], parentAvailable: true }))
+    child.resolve(remoteOk({ entries: [], parentAvailable: true }))
 
     await vi.waitFor(() => {
       expect(api.callsOf('session.list')).toHaveLength(1)
     })
     await vi.waitFor(() => {
-      expect(api.callsOf('subagent.list')).toEqual([
-        { parentSessionId: S1 },
-        { parentSessionId: S2 },
-      ])
+      expect(api.callsOf('subagents.list')).toEqual([S1, S2])
     })
     expect(manager.get(S2).getSnapshot().subagent).toEqual({
       address,
@@ -882,7 +878,7 @@ describe('completed reminder', () => {
   it('a list refresh carrying the running→idle transition arms the reminder', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     manager.select(S1)
     expect(entry(manager, S2)?.completed).toBe(false)
@@ -894,7 +890,7 @@ describe('completed reminder', () => {
   it('never arms for sessions already idle at first observation', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
     manager.select(S1)
     expect(entry(manager, S2)?.completed).toBe(false)
@@ -907,7 +903,7 @@ describe('completed reminder', () => {
     const api = new FakeApiClient()
     const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
     api.onList = () => gate.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshList()
     // The session finishes while the first pull is still in flight; the pull
     // response recorded it as running at pull time.
@@ -921,7 +917,7 @@ describe('completed reminder', () => {
     const api = new FakeApiClient()
     const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
     api.onList = () => gate.promise
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshList()
     // The unknown session starts and finishes while the first pull is in
     // flight; the pull-time baseline recorded it idle, so the running→idle

+ 7 - 7
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -103,7 +103,7 @@ describe('Session projection value semantics', () => {
 describe('Session tail-page seeding', () => {
   it('seeds the store from a history response carrying a projections block', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api))
+    const session = new Session(SID, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({
       records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
@@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => {
 
   it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api))
+    const session = new Session(SID, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({
       records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
@@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => {
 
   it('treats a blockless response as no reset: pushed values survive', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api))
+    const session = new Session(SID, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
     await session.open()
     session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
@@ -141,7 +141,7 @@ describe('manager frame routing', () => {
 
   it('lands projection frames before instantiation and the Session adopts the same store', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     manager.handleControlFrame({
       type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
     })
@@ -156,7 +156,7 @@ describe('manager frame routing', () => {
 
   it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
     }) as never)
@@ -181,7 +181,7 @@ describe('manager frame routing', () => {
 
   it('projects every retained value into list rows with stable snapshot identity', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{
         sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
@@ -208,7 +208,7 @@ describe('manager frame routing', () => {
 
   it('drops the projection store with the removed session', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote(api))
+    const manager = new SessionManager(fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
     }) as never)

+ 3 - 3
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -48,12 +48,12 @@ function makeSession(): Session {
 
 function makeBench(): { api: FakeApiClient; session: Session } {
   const api = new FakeApiClient()
-  return { api, session: new Session(SID, api, fakeRemote(api)) }
+  return { api, session: new Session(SID, fakeRemote(api)) }
 }
 
 function makeManager(): SessionManager {
   const api = new FakeApiClient()
-  return new SessionManager(api, fakeRemote(api))
+  return new SessionManager(fakeRemote(api))
 }
 
 describe('Session queue snapshot intake', () => {
@@ -210,7 +210,7 @@ describe('Session queue snapshot intake', () => {
 describe('queue operation transport', () => {
   it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api))
+    const session = new Session(SID, fakeRemote(api))
     session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
     const before = session.getSnapshot().queue
 

+ 15 - 13
packages/api/session-controller/tests/session.client.spec.ts

@@ -5,7 +5,7 @@ import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
+import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts'
 import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
 
 const SID = 'fk-s1' as SessionId
@@ -19,7 +19,7 @@ function makeSession(
   api = new FakeApiClient(),
   options: SessionOptions = {},
 ): { api: FakeApiClient; session: Session } {
-  return { api, session: new Session(SID, api, fakeRemote(api), options) }
+  return { api, session: new Session(SID, fakeRemote(api), options) }
 }
 
 function follow(
@@ -239,7 +239,7 @@ describe('paging', () => {
 describe('prompt and cancel errors', () => {
   it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api), {
+    const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
       parentAvailable: true,
     })
@@ -258,15 +258,17 @@ describe('prompt and cancel errors', () => {
       },
     ])
     expect(api.callsOf('subagent.history')).toEqual([])
-    expect(api.callsOf('subagent.prompt')).toEqual([
+    expect(api.callsOf('subagents.prompt')).toEqual([
       {
-        parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
+        requestId: expect.any(String) as unknown as string,
+        parentSessionId: PARENT, childSessionId: SID,
+        mode: 'continuable',
         content: [{ type: 'text', text: '继续' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
     ])
-    expect(api.callsOf('subagent.interrupt')).toEqual([
-      { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+    expect(api.callsOf('subagents.interruptByParent')).toEqual([
+      { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
     ])
     expect(api.callsOf('session.history')).toEqual([])
     expect(api.callsOf('session.prompt')).toEqual([])
@@ -281,10 +283,10 @@ describe('prompt and cancel errors', () => {
 
   it('lands an interrupt business failure in promptError with op=stop', async () => {
     const api = new FakeApiClient()
-    api.onSubagentInterrupt = () => Promise.resolve(err({
+    api.onSubagentInterrupt = () => Promise.resolve(remoteErr({
       code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
-    }) as never)
-    const session = new Session(SID, api, fakeRemote(api), {
+    }))
+    const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
       parentAvailable: true,
     })
@@ -298,7 +300,7 @@ describe('prompt and cancel errors', () => {
 
   it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(api), {
+    const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
     })
     await session.open()
@@ -316,8 +318,8 @@ describe('prompt and cancel errors', () => {
       },
     ])
     expect(api.callsOf('subagent.history')).toEqual([])
-    expect(api.callsOf('subagent.prompt')).toEqual([])
-    expect(api.callsOf('subagent.interrupt')).toEqual([])
+    expect(api.callsOf('subagents.prompt')).toEqual([])
+    expect(api.callsOf('subagents.interruptByParent')).toEqual([])
     expect(api.callsOf('session.cancel')).toEqual([])
   })
 

+ 10 - 9
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -18,6 +18,7 @@ import {
   err,
   fakeRemote,
   ok,
+  remoteOk,
   type RuntimeRemotes,
 } from './fake-api.client.ts'
 
@@ -33,7 +34,7 @@ function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Be
   const ctx = new Context()
   const api = new FakeApiClient()
   const remote = fakeRemote(api)
-  const svc = new ClientSessions(ctx, api, configureRemote?.(remote) ?? remote)
+  const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote)
   return { ctx, api, svc }
 }
 
@@ -522,9 +523,9 @@ describe('catalog-addressed navigation', () => {
   it('uses catalog labels for a listed addressed route', async () => {
     const b = bench()
     b.api.onSubagentList = (payload) => {
-      const { parentSessionId } = payload as { parentSessionId: SessionId }
+      const parentSessionId = payload as SessionId
       if (parentSessionId === sid('root')) {
-        return Promise.resolve(ok({
+        return Promise.resolve(remoteOk({
           entries: [{
             kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
             activity: 'inactive', hasChildren: true,
@@ -533,7 +534,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       if (parentSessionId === sid('child')) {
-        return Promise.resolve(ok({
+        return Promise.resolve(remoteOk({
           entries: [{
             kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
             activity: 'inactive', hasChildren: false,
@@ -541,7 +542,7 @@ describe('catalog-addressed navigation', () => {
           parentAvailable: false,
         }))
       }
-      return Promise.resolve(ok({ entries: [], parentAvailable: false }))
+      return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
     }
     await feedList(b, [
       { id: 'root' },
@@ -561,9 +562,9 @@ describe('catalog-addressed navigation', () => {
   it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
     const b = bench()
     b.api.onSubagentList = (payload) => {
-      const { parentSessionId } = payload as { parentSessionId: SessionId }
+      const parentSessionId = payload as SessionId
       if (parentSessionId === sid('root')) {
-        return Promise.resolve(ok({
+        return Promise.resolve(remoteOk({
           entries: [{
             kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
             activity: 'inactive', hasChildren: true,
@@ -572,7 +573,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       if (parentSessionId === sid('child')) {
-        return Promise.resolve(ok({
+        return Promise.resolve(remoteOk({
           entries: [{
             kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
             activity: 'inactive', hasChildren: false,
@@ -580,7 +581,7 @@ describe('catalog-addressed navigation', () => {
           parentAvailable: false,
         }))
       }
-      return Promise.resolve(ok({ entries: [], parentAvailable: false }))
+      return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
     }
     await feedList(b, [{ id: 'root' }])
     await b.svc.refreshSubagents(sid('root'))

+ 1 - 0
packages/api/session-controller/tsconfig.client.json

@@ -21,6 +21,7 @@
     { "path": "../../llm/llm" },
     { "path": "../../session/session-projection" },
     { "path": "../../session/session-title" },
+    { "path": "../../subagent/subagent" },
     { "path": "../../util/brand" },
     { "path": "../../util/crypto" },
     { "path": "../../util/workspace-path" },

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

@@ -14,7 +14,6 @@ export type {
   ModelReasoningEffort, ModelSelection,
   SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
   CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
-  SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
 export type {
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,

+ 11 - 10
packages/client/connection/src/client/fixture.ts

@@ -3260,13 +3260,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   }
 
   const api: ApiProxy = {
-    subagents: {
-      list: request => ok(request, { entries: [], parentAvailable: true }),
-      prompt: request => Promise.resolve(ok(request, {
-        messageId: `fixture-message-${request.payload.childSessionId}` as never,
-      })),
-      interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
-    },
     host: {
       describe: request => ok(request, {
         version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
@@ -3469,6 +3462,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string))
         case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string))
         case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string))
+        case 'subagents/list': return Promise.resolve({
+          ok: true,
+          value: { entries: [], parentAvailable: true },
+        })
+        case 'subagents/prompt': return Promise.resolve({
+          ok: true,
+          value: {
+            messageId: `fixture-message-${(request as { childSessionId: SessionId }).childSessionId}`,
+          },
+        })
+        case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } })
         case 'session/list': return sessionApi.list(
           args._request as Parameters<FixtureSessionApi['list']>[0],
         )
@@ -3591,9 +3595,6 @@ export class FixtureApiClient extends AbstractApiClient {
     signal: AbortSignal,
   ): Promise<RpcResponse<unknown>> {
     switch (method) {
-      case 'subagent.list': return this.api.subagents.list(request)
-      case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
-      case 'subagent.interrupt': return this.api.subagents.interrupt(request)
       case 'host.describe': return this.api.host.describe(request)
       case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
       case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

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

@@ -35,7 +35,6 @@ export type {
   SkillsApi, SkillEntry,
   ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
   MessageId, ModelReasoningEffort, ModelSelection,
-  SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
   ClientRequest, ServerResponse, RpcMessage,
   HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

+ 0 - 13
packages/client/connection/tests/fake-api.client.ts

@@ -69,19 +69,6 @@ export class FakeApiClient implements IApiClient {
 
   private readonly generationConns: StreamConn[] = []
 
-  readonly subagents: IApiClient['subagents'] = {
-    list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
-      entries: [],
-      parentAvailable: true,
-    }))),
-    prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
-      messageId: 'fake-message' as never,
-    }))),
-    interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
-      accepted: true as const,
-    }))),
-  }
-
   readonly host: IApiClient['host'] = {
     describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
     pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),

+ 2 - 2
packages/client/hmr/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/hmr/README.md
-README.md: 88438279301d4a893f2c245a6580e63ea0ca7930
-README.zh.md: f56abdbb0473a850af933be0653e217fd51668de
+README.md: de420ef2c809ccc13feea320ae3ffda3720f82a3
+README.zh.md: c7066e369b38fa3ffda6888831bba7780c5da8af

+ 1 - 1
packages/client/hmr/README.md

@@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove
 
 ### Design concept
 
-The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. It also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
+The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
 
 ### The browser swap
 

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