Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into xtr/session-projection-migrations

_Kerman 1 lună în urmă
părinte
comite
bd6f72cbbb
100 a modificat fișierele cu 1423 adăugiri și 244 ștergeri
  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/bug-fix/2026-08-26-composer-gesture-editable-gate.i18n.yaml
  20. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md
  21. 25 0
      .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md
  22. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.i18n.yaml
  23. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.md
  24. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-pi-ai-grant-payload-json-image.zh.md
  25. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml
  26. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md
  27. 39 0
      .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md
  28. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  29. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  30. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  31. 6 7
      .github/workflows/build-exe-for-python-sdk.yml
  32. 1 1
      .github/workflows/ci.yml
  33. 7 7
      apps/web/tests/permission-policy-context.e2e.ts
  34. 15 0
      apps/web/tests/question-composer.e2e.ts
  35. 10 0
      apps/web/tests/support.ts
  36. 26 6
      apps/web/tests/trajectory-image-display.expected.e2e.ts
  37. 2 2
      docs/config-catalog.i18n.yaml
  38. 1 1
      docs/config-catalog.md
  39. 1 1
      docs/config-catalog.zh.md
  40. 2 2
      docs/subsystems/client-modules.i18n.yaml
  41. 2 6
      docs/subsystems/client-modules.md
  42. 2 6
      docs/subsystems/client-modules.zh.md
  43. 2 2
      docs/subsystems/persistence.i18n.yaml
  44. 1 1
      docs/subsystems/persistence.md
  45. 1 1
      docs/subsystems/persistence.zh.md
  46. 2 2
      docs/subsystems/slots.i18n.yaml
  47. 2 0
      docs/subsystems/slots.md
  48. 2 0
      docs/subsystems/slots.zh.md
  49. 2 2
      packages/client/hmr/README.i18n.yaml
  50. 1 1
      packages/client/hmr/README.md
  51. 1 1
      packages/client/hmr/README.zh.md
  52. 15 25
      packages/client/hmr/src/index.ts
  53. 7 15
      packages/client/hmr/tests/node-half.client.spec.ts
  54. 2 15
      packages/client/modules/src/index.ts
  55. 0 2
      packages/client/modules/tests/node-half.client.spec.ts
  56. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  57. 4 0
      packages/client/ui-settings-models/README.md
  58. 4 0
      packages/client/ui-settings-models/README.zh.md
  59. 54 6
      packages/client/ui-settings-models/src/client/ModelsSection.tsx
  60. 5 0
      packages/client/ui-settings-models/src/client/index.ts
  61. 56 0
      packages/client/ui-settings-models/src/client/slot-contract.ts
  62. 17 7
      packages/client/ui-settings-models/src/client/store.ts
  63. 25 0
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  64. 86 1
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  65. 2 1
      packages/client/ui-settings-models/tests/invariant.client.spec.ts
  66. 2 0
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  67. 14 4
      packages/client/ui-settings-models/tests/store.client.spec.ts
  68. 2 2
      packages/client/ui-user-questions/README.i18n.yaml
  69. 6 6
      packages/client/ui-user-questions/README.md
  70. 6 6
      packages/client/ui-user-questions/README.zh.md
  71. 2 1
      packages/client/ui-user-questions/package.json
  72. 67 46
      packages/client/ui-user-questions/src/client/QuestionComposer.tsx
  73. 8 4
      packages/client/ui-user-questions/src/client/contract/slots.ts
  74. 57 0
      packages/client/ui-user-questions/src/client/draft-store.ts
  75. 3 0
      packages/client/ui-user-questions/src/client/index.ts
  76. 5 0
      packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts
  77. 5 0
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  78. 36 0
      packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts
  79. 27 5
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  80. 3 0
      packages/client/ui-user-questions/tsconfig.json
  81. 1 0
      packages/core/session/src/index.ts
  82. 70 0
      packages/core/session/src/seq-ranges.ts
  83. 36 0
      packages/core/session/tests/seq-ranges.spec.ts
  84. 2 2
      packages/experimental/webworker-packer/README.i18n.yaml
  85. 2 1
      packages/experimental/webworker-packer/README.md
  86. 2 1
      packages/experimental/webworker-packer/README.zh.md
  87. 2 2
      packages/experimental/webworker-runtime/README.i18n.yaml
  88. 1 1
      packages/experimental/webworker-runtime/README.md
  89. 1 1
      packages/experimental/webworker-runtime/README.zh.md
  90. 62 8
      packages/experimental/webworker-runtime/src/compile/transform.ts
  91. 5 1
      packages/experimental/webworker-runtime/src/node/globals/process.ts
  92. 29 0
      packages/experimental/webworker-runtime/tests/compile/transform.spec.ts
  93. 1 0
      packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts
  94. 80 0
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  95. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  96. 25 1
      packages/llm/llm-pi-ai/src/auth.ts
  97. 41 0
      packages/llm/llm-pi-ai/tests/auth.spec.ts
  98. 2 2
      packages/session/session-persistence-jsonl/README.i18n.yaml
  99. 2 2
      packages/session/session-persistence-jsonl/README.md
  100. 2 2
      packages/session/session-persistence-jsonl/README.zh.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: 8731528b9ae600bb8bfe12738669f3a84c11a06b
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 755b4bd7ddbe9b88b4f40b8a3ae7419f746b8dde
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Fișier diff suprimat deoarece este prea mare
+ 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

Fișier diff suprimat deoarece este prea mare
+ 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/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

Fișier diff suprimat deoarece este prea mare
+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md


Fișier diff suprimat deoarece este prea mare
+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md


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

+ 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 page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
+      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)
     }

+ 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: d6a09507d90437951b8cdcbcdaf7757cf267600f
-config-catalog.zh.md: 1caa38f1997148fbb4d7457f8ff326376bb88c57
+config-catalog.md: c3471c8098ba1eabfef82c46354a5d858637963a
+config-catalog.zh.md: 89433a38b90cf75b9a6fa53929920ca39dcbfdc4

+ 1 - 1
docs/config-catalog.md

@@ -1309,7 +1309,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>
 

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

@@ -1311,7 +1311,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/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
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
 

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

@@ -59,7 +59,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle 及其可选 source map。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。它还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。
+链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。
 
 ### 浏览器侧替换
 

+ 15 - 25
packages/client/hmr/src/index.ts

@@ -1,7 +1,7 @@
 /**
  * HMR plugin, node half: the host end of the dev reload chain. One interval
- * stat-polls every graph row's client bundle and optional source map (polling
- * by design: network mounts deliver no inotify events), reports changes through
+ * stat-polls every graph row's client bundle (polling by design: network mounts
+ * deliver no inotify events), reports changes through
  * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
  * The web bundle mounts this row unconditionally: without a rebuild
@@ -42,30 +42,22 @@ function sseData(frame: PluginsEventFrame): string {
   return `data: ${JSON.stringify(frame)}\n\n`
 }
 
-type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'>
+type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
 
 type WatchedBundle = {
   -readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
 } & { dirty: boolean }
 
-/** Snapshot the bundle plus its optional development source map. */
-function artifactStat(path: string): WatchedArtifactStat {
+/** Snapshot the executable bundle metadata that drives reloads. */
+function bundleStat(path: string): WatchedBundleStat {
   const bundle = statSync(path)
-  try {
-    const map = statSync(`${path}.map`)
-    return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size }
-  } catch (error) {
-    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-    return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
-  }
+  return { mtimeMs: bundle.mtimeMs, size: bundle.size }
 }
 
-/** Whether neither served artifact changed since the last successful re-hash. */
-function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean {
+/** Whether the executable bundle is unchanged since the last successful re-hash. */
+function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
   return left.mtimeMs === right.mtimeMs
     && left.size === right.size
-    && left.mapMtimeMs === right.mapMtimeMs
-    && left.mapSize === right.mapSize
 }
 
 /**
@@ -80,7 +72,7 @@ export function apply(ctx: Context, config: Config): void {
   // --- bundle watch: one HMR-owned stat poll ------------------------------
   const watched = new Map<string, WatchedBundle>()
 
-  const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => {
+  const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
     try {
       // rebuilt() replaces the opaque startup rev on its first call; later
       // calls stay silent when the content hash is unchanged.
@@ -95,17 +87,15 @@ export function apply(ctx: Context, config: Config): void {
     }
     watch.mtimeMs = current.mtimeMs
     watch.size = current.size
-    watch.mapMtimeMs = current.mapMtimeMs
-    watch.mapSize = current.mapSize
     watch.dirty = false
   }
 
   const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
     const watch: WatchedBundle = { ...baseline, dirty: false }
     watched.set(id, watch)
-    let current: WatchedArtifactStat
+    let current: WatchedBundleStat
     try {
-      current = artifactStat(baseline.path)
+      current = bundleStat(baseline.path)
     } catch (error) {
       watch.dirty = true
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
@@ -113,20 +103,20 @@ export function apply(ctx: Context, config: Config): void {
     }
     // The module host captured its baseline before reading the bytes in the
     // startup batch. Only a mismatch crosses into the content-hash path.
-    if (!sameArtifactStat(current, watch)) rehash(id, watch, current)
+    if (!sameBundleStat(current, watch)) rehash(id, watch, current)
   }
 
   const pollWatches = (): void => {
     for (const [id, watch] of watched) {
-      let current: WatchedArtifactStat
+      let current: WatchedBundleStat
       try {
-        current = artifactStat(watch.path)
+        current = bundleStat(watch.path)
       } catch (error) {
         watch.dirty = true
         if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
         continue
       }
-      if (!watch.dirty && sameArtifactStat(current, watch)) continue
+      if (!watch.dirty && sameBundleStat(current, watch)) continue
       // Stat-before-hash preserves a detectable older baseline for writes that
       // land during hashing. Repeated stat changes heal a torn read.
       rehash(id, watch, current)

+ 7 - 15
packages/client/hmr/tests/node-half.client.spec.ts

@@ -31,19 +31,7 @@ interface FakeHostOptions {
 
 function artifactBaseline(path: string): ClientArtifactBaseline {
   const bundle = statSync(path)
-  try {
-    const sourceMap = statSync(`${path}.map`)
-    return {
-      path,
-      mtimeMs: bundle.mtimeMs,
-      size: bundle.size,
-      mapMtimeMs: sourceMap.mtimeMs,
-      mapSize: sourceMap.size,
-    }
-  } catch (error) {
-    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-    return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
-  }
+  return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
 }
 
 function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
@@ -111,7 +99,7 @@ async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
 }
 
 describe('hmr node half', () => {
-  it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
+  it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => {
     const bundle = join(dir, 'a.js')
     writeFileSync(bundle, 'v1')
     const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
@@ -130,13 +118,17 @@ describe('hmr node half', () => {
     clientModuleHost.rebuiltCalls.length = 0
     await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
     writeFileSync(`${bundle}.map`, '{"version":3}')
+    await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
+    expect(clientModuleHost.rebuiltCalls).toEqual([])
+
+    writeFileSync(bundle, 'v3-even-longer')
     await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
 
     await fiber.dispose()
     expect(routes).toHaveLength(0)
     // Watcher gone: further file changes report nothing.
     clientModuleHost.rebuiltCalls.length = 0
-    writeFileSync(bundle, 'v3-even-longer')
+    writeFileSync(bundle, 'v4-after-dispose')
     await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
     expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
   })

+ 2 - 15
packages/client/modules/src/index.ts

@@ -23,7 +23,7 @@
  */
 
 import { createHash, randomBytes } from 'node:crypto'
-import { readFileSync, statSync, type Stats } from 'node:fs'
+import { readFileSync, statSync } from 'node:fs'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { createRequire } from 'node:module'
 import { dirname, join } from 'node:path'
@@ -78,10 +78,6 @@ export 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
 }
 
 /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
@@ -756,22 +752,13 @@ export class ClientModuleRegistry extends Service {
     return meta
   }
 
-  /** Capture the bundle and optional-map stats before reading their bytes. */
+  /** Capture the bundle stats before reading its bytes. */
   private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
     const bundle = statSync(clientPath)
-    let sourceMap: Stats | undefined
-    try {
-      sourceMap = statSync(`${clientPath}.map`)
-    } catch {
-      // Optional map metadata only seeds HMR; the following map read reports
-      // malformed or inaccessible bytes and a later stat change self-heals.
-    }
     return {
       path: clientPath,
       mtimeMs: bundle.mtimeMs,
       size: bundle.size,
-      mapMtimeMs: sourceMap?.mtimeMs ?? null,
-      mapSize: sourceMap?.size ?? null,
     }
   }
 

+ 0 - 2
packages/client/modules/tests/node-half.client.spec.ts

@@ -378,8 +378,6 @@ describe('client bundle activation', () => {
       path: firstPath,
       mtimeMs: firstStat.mtimeMs,
       size: firstStat.size,
-      mapMtimeMs: null,
-      mapSize: null,
     })
     expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
   })

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: c1d2e08a32d10f6d0e2bb9bb13e085d69d6ece92
-README.zh.md: 0204494c7856b96d6f0c17c580abea31e49c3516
+README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
+README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f

+ 4 - 0
packages/client/ui-settings-models/README.md

@@ -47,6 +47,10 @@ The add flow is a card carrying the dormant-directory provider select — a bare
 
 After the versioned notice step completes, the DeepSeek step projects first-run readiness from the same joined snapshot. ANY provider the user can already reach ends it without rendering; only a user with none is asked for the official DeepSeek key. Configure later completes only this coordinator pass, and an absent adapter, inactive route, failed join, read-only deployment, or unusable capability completes the step without rendering — Models remains the diagnostic surface.
 
+### Extension slots
+
+The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 4 - 0
packages/client/ui-settings-models/README.zh.md

@@ -47,6 +47,10 @@ kind: "package-reference"
 
 版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。
 
+### 扩展插槽
+
+本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 54 - 6
packages/client/ui-settings-models/src/client/ModelsSection.tsx

@@ -16,7 +16,9 @@ import { useState } from 'react'
 import type { ReactNode } from 'react'
 import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
 import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: pulls this package's SlotMap merge (the two Models child slots).
+import type {} from './slot-contract.ts'
 import { CustomProviderCard } from './CustomProviderCard.tsx'
 import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
 import type { ModelsSettingsStore, ProviderRow } from './store.ts'
@@ -42,11 +44,20 @@ export interface ModelsSectionInjected {
   t: (key: keyof typeof en) => string
 }
 
+/** The child slots this section declares and dispatches (see ./slot-contract.ts). */
+type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer'
+
+/** The child-slot dispatch function the renderer binds for the section. */
+type ModelsRenderSlot = PropsRenderSlots<ModelsChildSlots>['renderSlot']
+
 /**
  * Props delivered by the slot outlet: the inject face spread flat (the
- * renderer erases the share boundary at the render call).
+ * renderer erases the share boundary at the render call) plus the child-slot
+ * dispatch seat. The seat is required: the renderer binds it at the render
+ * call itself — unlike the inject face it is never absent at runtime — and a
+ * direct render that forgets it fails to compile instead of mounting nothing.
  */
-export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>>
+export type ModelsSectionProps = Partial<InjectFace<ModelsSectionInjected>> & PropsRenderSlots<ModelsChildSlots>
 
 type ModelsSectionFace = InjectFace<ModelsSectionInjected>
 
@@ -139,6 +150,19 @@ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
   return row.credential?.configured !== true
 }
 
+/**
+ * The provider-card seat's credential fact: the reference this page would use
+ * for the row — the profile's `apiKeyEnv`, or the page's derived
+ * `<ROUTE>_API_KEY` while the profile names none — confirmed configured. The
+ * derived half is what keeps the seat consistent with the editor on the
+ * add-provider draft, whose dormant row names no reference yet.
+ */
+function keyConfiguredOf(row: ProviderRow): boolean {
+  return row.apiKeyEnv !== undefined
+    ? row.credential?.configured === true
+    : row.derivedCredential?.configured === true
+}
+
 function targetOf(row: ProviderRow): EditorTarget {
   const managedRef = deriveKeyRef(row.entry.provider)
   const credentialRef = row.apiKeyEnv === managedRef
@@ -175,15 +199,15 @@ export function providerCopy(template: string, target: ProviderIdentity): string
  * @returns the section, or null while the shell has not injected yet.
  */
 export function ModelsSection(props: ModelsSectionProps): ReactNode {
-  const { controller, useSnapshot, api, schema, t } = props
+  const { controller, useSnapshot, api, schema, t, renderSlot } = props
   if (
     controller === undefined || useSnapshot === undefined || api === undefined
     || schema === undefined || t === undefined
   ) return null
-  return <Loaded injected={{ controller, useSnapshot, api, schema, t }} />
+  return <Loaded injected={{ controller, useSnapshot, api, schema, t }} renderSlot={renderSlot} />
 }
 
-function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
+function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode {
   const { controller, api, schema, t } = injected
   const state = injected.useSnapshot(snapshot => snapshot)
   const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
@@ -275,6 +299,12 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
   const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
   const addTarget = adding ? editing : undefined
   const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
+  // The draft's directory row, for the card extension seat. A refresh can drop
+  // the row mid-draft (the route was adopted or withdrawn elsewhere); the
+  // draft card stays while the seat simply has no row to dispatch.
+  const addRow = addTarget === undefined
+    ? undefined
+    : state.rows.find(row => row.entry.provider === addTarget.provider)
   // Hand-declared routes live in the pi-ai namespace, which is also the only
   // one whose schema names the protocols one may speak; without it mounted
   // there is nothing to declare and the entry point stays disabled.
@@ -324,6 +354,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
                   readOnly: !state.writable,
                   onClose: (changed) => { closeSetup(changed, target) },
                 })}
+                {renderSlot(
+                  'settings.models.provider-card',
+                  { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
+                  { entryKey: row.entry.settingsNs },
+                )}
               </li>
             )
           }
@@ -399,6 +434,11 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
                     : null}
                 </span>
               </div>
+              {renderSlot(
+                'settings.models.provider-card',
+                { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
+                { entryKey: row.entry.settingsNs },
+              )}
               {open
                 ? renderProviderEditor({
                   target,
@@ -449,6 +489,13 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
                 readOnly={!state.writable}
                 onClose={(changed) => { closeEditor(changed, addTarget) }}
               />
+              {addRow === undefined
+                ? null
+                : renderSlot(
+                  'settings.models.provider-card',
+                  { provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
+                  { entryKey: addRow.entry.settingsNs },
+                )}
             </div>
           )
           : declaring
@@ -509,6 +556,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
               </div>
             )}
       </div>
+      {renderSlot('settings.models.footer', {})}
       <Modal
         open={deleteTarget !== undefined}
         onClose={closeDelete}

+ 5 - 0
packages/client/ui-settings-models/src/client/index.ts

@@ -29,6 +29,7 @@ import { en, zh, type ModelsKey } from './locales.ts'
 import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
 
 export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
+export type { ModelsFooterOwnerProps, ProviderCardExtrasOwnerProps } from './slot-contract.ts'
 export type { ModelsKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -125,6 +126,10 @@ export function apply(ctx: ClientContext): void {
     order: 10,
     label: () => t('nav'),
     inject: injected,
+    children: {
+      'settings.models.provider-card': { kind: 'keyed', scope: 'root' },
+      'settings.models.footer': { kind: 'list', scope: 'root' },
+    },
   }, ModelsSection))
   ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
     name: 'settings.onboarding',

+ 56 - 0
packages/client/ui-settings-models/src/client/slot-contract.ts

@@ -0,0 +1,56 @@
+/**
+ * Models-page extension slots — the two seats through which a plugin
+ * distributed outside this repository adds UI to the Models settings section
+ * without editing it.
+ *
+ * `settings.models.provider-card` is keyed by the row's owning settings
+ * namespace (`ConfigurableProviderView.settingsNs`): an adapter family's
+ * companion plugin registers one entry under the family's namespace and
+ * receives every card of that family — shipped, added, and hand-declared rows
+ * alike — while the section never learns what the namespace means. Keying on
+ * the namespace follows `settings.plugin.item`, and the key domain stays the
+ * open string space because hand-declared route ids are user-chosen at
+ * runtime.
+ *
+ * TYPE HOME RATIONALE: the Models section declares these slots at runtime,
+ * and a plugin registering an extension already depends on this package for
+ * the declaration. The types therefore live with their declarer.
+ */
+
+import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client'
+import type {} from '@deepseek-ai/dsh-client-ui-slots'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /**
+     * One provider card's adapter extension area, dispatched with
+     * `entryKey = settingsNs` on every card that renders a directory row: a
+     * saved row's card (its first-run setup posture included) and the
+     * add-provider draft card. The hand-declared draft card has no directory
+     * row yet, so it dispatches nothing until saved. Without a registrant the
+     * area renders nothing.
+     */
+    'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps }
+    /**
+     * Ordered extension area after the provider rows and the add controls.
+     * Without a registrant the area renders nothing.
+     */
+    'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps }
+  }
+}
+
+/** Owner share of one provider-card extension occurrence. */
+export interface ProviderCardExtrasOwnerProps {
+  /** The card's directory row (route id, display name, settings address, live state). */
+  provider: ConfigurableProviderView
+  /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */
+  configured: boolean
+  /** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */
+  keyConfigured: boolean
+}
+
+/** Owner share of the footer area (the section supplies nothing). */
+export interface ModelsFooterOwnerProps {
+  /** Marker field: footer owner props are intentionally empty. */
+  children?: never
+}

+ 17 - 7
packages/client/ui-settings-models/src/client/store.ts

@@ -32,6 +32,13 @@ export interface ProviderRow {
   apiKeyEnv: string | undefined
   /** Credential state for {@link apiKeyEnv}, once described. */
   credential: CredentialView | undefined
+  /**
+   * Credential state for the page's derived `<ROUTE>_API_KEY`, described only
+   * while the profile names no reference — the provider-card seat's
+   * `keyConfigured` fact for dormant and keyless rows, matching the editor's
+   * own derivation rule.
+   */
+  derivedCredential?: CredentialView
 }
 
 /** Page snapshot. */
@@ -185,7 +192,7 @@ export class ModelsSettingsStore {
         credential: undefined,
       }
     })
-    const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
+    const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))]
     let credentials: Record<string, CredentialView> = {}
     let credentialError: string | null = null
     if (refs.length > 0) {
@@ -206,12 +213,15 @@ export class ModelsSettingsStore {
       s.error = null
       s.credentialError = credentialError
       s.writable = writable
-      s.rows = rows.map(row => ({
-        ...row,
-        ...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
-          ? { credential: credentials[row.apiKeyEnv] }
-          : {},
-      }))
+      s.rows = rows.map((row) => {
+        const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv]
+        const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)]
+        return {
+          ...row,
+          ...named === undefined ? {} : { credential: named },
+          ...derived === undefined ? {} : { derivedCredential: derived },
+        }
+      })
       s.namespaces = namespaces
     })
   }

+ 25 - 0
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -61,6 +61,9 @@ describe('ui-settings-models apply', () => {
     const entry = before.slots.entries('settings.section')[0]!
     expect(entry.component).toBe(ModelsSection)
     expect(entry.options).toMatchObject({ id: 'models', order: 10 })
+    // The section claims its two extension seats in the same registration.
+    expect(before.slots.spec('settings.models.provider-card')).toMatchObject({ kind: 'keyed', scope: 'root' })
+    expect(before.slots.spec('settings.models.footer')).toMatchObject({ kind: 'list', scope: 'root' })
     // The nav label is a locale-following thunk; owners resolve at read time.
     expect(resolveSlotLabel(entry.options.label)).toBe('模型')
     const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
@@ -137,6 +140,28 @@ describe('ui-settings-models apply', () => {
     b.locale.setLocale('zh')
   })
 
+  it('accepts extension entries under the declared seats and cascades them with the declarer', async () => {
+    const b = await bench()
+    declare(b.slots)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    // A keyed card extension and a footer entry register through the ordinary
+    // ledger once the section's registration declared the seats.
+    const disposeCard = b.slots.register(
+      { name: 'settings.models.provider-card', key: 'llm-pi-ai' } as never,
+      () => null,
+    )
+    b.slots.register({ name: 'settings.models.footer', id: 'extra', order: 0 } as never, () => null)
+    expect(b.slots.entries('settings.models.provider-card')).toHaveLength(1)
+    expect(b.slots.entries('settings.models.footer')).toHaveLength(1)
+    // Extension-side HMR safety: its own disposer removes the entry.
+    disposeCard()
+    expect(b.slots.entries('settings.models.provider-card')).toHaveLength(0)
+    // Declarer unload cascades whatever extension entries remain.
+    await fiber.dispose()
+    expect(b.slots.entries('settings.models.footer')).toHaveLength(0)
+  })
+
   it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => {
     const b = await bench()
     declare(b.slots)

+ 86 - 1
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -195,20 +195,44 @@ function scriptedFace(overrides: {
 
 type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
 
+/** One recorded child-slot dispatch: seat name, owner share, kind options. */
+type RenderSlotCall = [name: string, owner: Record<string, unknown>, opts?: { entryKey?: string }]
+
+/** Child-slot dispatch stub: records every seat occurrence, renders nothing. */
+function stubRenderSlot() {
+  return vi.fn((..._call: RenderSlotCall) => null)
+}
+
+/** The provider-card seat dispatches a stub recorded, as (route id, configured, keyConfigured, entryKey). */
+function cardSeatCalls(
+  renderSlot: ReturnType<typeof stubRenderSlot>,
+): Array<[string, boolean, boolean, string | undefined]> {
+  return renderSlot.mock.calls
+    .filter(call => call[0] === 'settings.models.provider-card')
+    .map(call => [
+      (call[1] as { provider: { provider: string } }).provider.provider,
+      (call[1] as { configured: boolean }).configured,
+      (call[1] as { keyConfigured: boolean }).keyConfigured,
+      call[2]?.entryKey,
+    ])
+}
+
 async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
   const { face, update, replace, mutate, set, unset } = scripted
   const mirror = new SettingsDescribeMirror(face as never)
   const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
   await controller.load()
+  const renderSlot = stubRenderSlot()
   const injected: ModelsSectionProps = {
     controller,
     useSnapshot: bindSnapshotSelector(controller.store),
     api: face as never,
     schema: settingsSchema,
     t,
+    renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
   }
   const view = render(<ModelsSection {...injected} />)
-  return { view, face, update, replace, mutate, set, unset, controller, mirror }
+  return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot }
 }
 
 async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
@@ -246,6 +270,61 @@ describe('ModelsSection', () => {
     expect(document.body.textContent).toBe('')
   })
 
+  it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => {
+    const { renderSlot } = await mountSection()
+    const cards = cardSeatCalls(renderSlot)
+    expect(cards).toContainEqual(['openai', true, true, 'llm-pi-ai'])
+    expect(cards).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
+    // The footer seat renders once below the rows and the add controls.
+    expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.models.footer')).toEqual([
+      ['settings.models.footer', {}],
+    ])
+  })
+
+  it('dispatches the provider-card seat inside the first-run setup card', async () => {
+    const { renderSlot } = await mountFirstRun()
+    expect(cardSeatCalls(renderSlot)).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
+  })
+
+  it('dispatches the provider-card seat on the add-provider draft with its dormant row', async () => {
+    const { renderSlot } = await mountSection()
+    renderSlot.mockClear()
+    fireEvent.click(screen.getByRole('button', { name: en.add }))
+    expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai'])
+  })
+
+  it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => {
+    const scripted = scriptedFace()
+    scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
+      credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
+        configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY',
+        writable: true,
+      }])),
+    })))
+    const { renderSlot } = await mountFace(scripted)
+    renderSlot.mockClear()
+    fireEvent.click(screen.getByRole('button', { name: en.add }))
+    // The dormant row names no reference yet; the seat still reports the
+    // derived ANTHROPIC_API_KEY the editor itself displays as configured.
+    expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, true, 'llm-pi-ai'])
+  })
+
+  it('skips the draft seat when a refresh drops the dormant row', async () => {
+    const { renderSlot, face, controller } = await mountSection()
+    fireEvent.click(screen.getByRole('button', { name: en.add }))
+    face.llm.providers.mockImplementation(() => Promise.resolve(ok({
+      providers: [
+        { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
+        { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
+      ],
+    })))
+    renderSlot.mockClear()
+    await act(async () => { await controller.load() })
+    // The draft card is still open while its row is gone from the directory.
+    expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
+    expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
+  })
+
   it('persists the default-off subagent model-selection switch for new sessions', async () => {
     const enabledNamespace: SettingsNamespaceView = {
       ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
@@ -352,6 +431,7 @@ describe('ModelsSection', () => {
       api={face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
 
     const missing = screen.getByRole('img', { name: en.credentialMissing })
@@ -376,6 +456,7 @@ describe('ModelsSection', () => {
       api={face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
     // Now a row with an Edit button, not an open card.
     expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
@@ -1106,6 +1187,7 @@ describe('ModelsSection', () => {
         api={face as never}
         schema={settingsSchema}
         t={t}
+        renderSlot={() => null}
       />)
       const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
       expect(key.placeholder).toBe(en.keyPlaceholder)
@@ -1245,6 +1327,7 @@ describe('ModelsSection', () => {
       api={face.face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
     expect(screen.getByText(/directory down/)).toBeTruthy()
     fireEvent.click(screen.getByText(en.retry))
@@ -1267,6 +1350,7 @@ describe('ModelsSection', () => {
       api={face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
     expect(screen.getByText(en.readOnly)).toBeTruthy()
     expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
@@ -1328,6 +1412,7 @@ describe('ModelsSection', () => {
       api={face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
     await screen.findByText('DeepSeek')
   })

+ 2 - 1
packages/client/ui-settings-models/tests/invariant.client.spec.ts

@@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
 import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant'
 import InvariantRegistry from '@deepseek-ai/dsh-invariants'
 import { ModelsSection } from '../src/client/ModelsSection.tsx'
+import type { ModelsSectionProps } from '../src/client/ModelsSection.tsx'
 
 describe('invariant companion', () => {
   it('registers under the package name with an empty installer', async () => {
@@ -18,6 +19,6 @@ describe('invariant companion', () => {
   })
 
   it('renders null until the shell injects the section dependencies', () => {
-    expect(ModelsSection({})).toBeNull()
+    expect(ModelsSection({} as ModelsSectionProps)).toBeNull()
   })
 })

+ 2 - 0
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -150,6 +150,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
     api: scripted.face as never,
     schema: settingsSchema,
     t,
+    renderSlot: () => null,
   }
   render(<ModelsSection {...injected} />)
   return { ...scripted, controller }
@@ -671,6 +672,7 @@ describe('provider rows', () => {
       api={scripted.face as never}
       schema={settingsSchema}
       t={t}
+      renderSlot={() => null}
     />)
 
     // Absent is "unknown", never "shipped": an adapter that answers nothing

+ 14 - 4
packages/client/ui-settings-models/tests/store.client.spec.ts

@@ -81,7 +81,9 @@ describe('ModelsSettingsStore', () => {
     expect(state.status).toBe('ready')
     expect(state.writable).toBe(true)
     expect(state.credentialError).toBeNull()
-    expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
+    // Named references first (rows order), then the derived <ROUTE>_API_KEY
+    // of every row whose profile names none — one batched describe.
+    expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GHOST_API_KEY']])
     const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
     expect(byProvider.get('deepseek-official')).toMatchObject({
       configured: true,
@@ -196,7 +198,7 @@ describe('edge joins', () => {
     expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
   })
 
-  it('skips the credential describe entirely when no row names a reference', async () => {
+  it('describes the derived reference for a row whose profile names none', async () => {
     const { face, mirror, seenRefs } = api({
       describeSettings: () => Promise.resolve(ok({
         writable: true,
@@ -208,11 +210,19 @@ describe('edge joins', () => {
           { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
         ] as never,
       })),
+      describeCredentials: refs => Promise.resolve(ok({
+        credentials: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
+      })),
     })
     const store = new ModelsSettingsStore(face, settingsSchema, mirror)
     await store.load()
-    expect(seenRefs).toEqual([])
-    expect(store.store.getSnapshot().status).toBe('ready')
+    // The dormant row names no reference, so the join asks about the page's
+    // own derived <ROUTE>_API_KEY — what the editor would display for it.
+    expect(seenRefs).toEqual([['ANTHROPIC_API_KEY']])
+    const state = store.store.getSnapshot()
+    expect(state.status).toBe('ready')
+    expect(state.rows[0]?.credential).toBeUndefined()
+    expect(state.rows[0]?.derivedCredential).toMatchObject({ configured: true })
   })
 
   it('surfaces a settings describe failure', async () => {

+ 2 - 2
packages/client/ui-user-questions/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
-README.md: 51095ef4b8946657d786abddb5dc01cbd462eddb
-README.zh.md: ec91254b1ace817ce2e2b667870e7512e2879a2d
+README.md: 2462a3d25644cf073b4652d564a9f7a213e92250
+README.zh.md: 296974703d77791393811b89fd046078969584b5

+ 6 - 6
packages/client/ui-user-questions/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
+`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` chain, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it.
 
 ## Table of Contents
 
@@ -29,15 +29,15 @@ When the agent asks a question, the composer becomes the question surface: answe
 
 ### Answering
 
-A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
+A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
 
 ### The plan-review card
 
-A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
+A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
 
 ### Failure and recovery
 
-Selection state is local to a component keyed by the request rpcId: a replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
+The generic question flow keeps its current page, selected labels, custom text, and explicit skips in a non-persisted Slot store scoped to the owning Session and keyed by the pending request's local render identity. Switching from Session A to B remounts the strict composer entry, but returning to A reuses A's store and restores the unfinished draft. A different request identity reads an empty draft and replaces the previous value on its first edit; a successful answer or cancellation clears the matching value. The host remains authoritative for whether the request is pending.
 
 -----
 
@@ -76,7 +76,7 @@ These pages cover the composer host, the tool seam, and the plan-mode consumer.
 <a id="model-experience"></a>
 ## Model Experience
 
-Indirectly, through dsh-tool-ask-user, which the package mounts and which owns the model-visible schema and answer rendering.
+Indirectly, through `dsh-tool-ask-user`, whose model-visible schema and answer rendering this package presents in the Web client.
 
 #### KV Cache effect
 
@@ -89,7 +89,7 @@ No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and
 
 These limits define draft durability and composer ownership; they are current package constraints.
 
-- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
+- **Unsubmitted drafts have page-and-Session lifetime** — Session navigation preserves them while that Session scope remains in the page, but a full page reload, Session pruning, or a newly delivered pending-request identity starts with an empty draft. The store never writes them to the Host, `localStorage`, or disk.
 - **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
 
 <a id="dev-note"></a>

+ 6 - 6
packages/client/ui-user-questions/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
+`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` chain 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。
 
 ## 目录
 
@@ -29,15 +29,15 @@ kind: "package-reference"
 
 ### 作答
 
-用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
+用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
 
 ### plan-review 卡片
 
-`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
+`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
 
 ### 失败与恢复
 
-选择状态只存在于以请求 rpcId 为 key 的组件本地:使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
+通用提问流程把当前题号、已选标签、自定义文本和显式跳过状态保存在非持久化 Slot store 中;该 store 归属对应 Session,并以待处理请求的本地渲染标识为 key。从 Session A 切换到 B 会重新挂载严格 Session 级编辑器条目,但返回 A 时会复用 A 的 store 并恢复未完成草稿。不同的请求标识读取空草稿,并在首次编辑时替换旧值;成功回答或取消会清除相符的值。请求是否仍在等待由主机保持权威。
 
 -----
 
@@ -76,7 +76,7 @@ kind: "package-reference"
 <a id="model-experience"></a>
 ## 模型体验
 
-间接影响模型体验:通过 `dsh-tool-ask-user` 实现,本包挂载该工具,而该工具拥有模型可见的 schema 与答案渲染。
+间接影响模型体验:本包在 Web 客户端呈现 `dsh-tool-ask-user` 所拥有的模型可见 schema 与答案渲染。
 
 #### KV Cache 影响
 
@@ -89,7 +89,7 @@ kind: "package-reference"
 
 这些限制定义草稿持久性与编辑器归属;它们是当前包约束。
 
-- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。
+- **未提交草稿的生命周期限于当前页面与 Session**:只要该 Session scope 仍留在页面内,Session 导航就会保留草稿;完整刷新页面、Session 被裁剪,或待处理请求以新的本地标识重新交付时,则从空草稿开始。store 从不把草稿写入主机、`localStorage` 或磁盘。
 - **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
 
 <a id="dev-note"></a>

+ 2 - 1
packages/client/ui-user-questions/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-user-questions",
-  "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
+  "description": "Web ask_user_question composer takeover and plan-review presentation UI",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -69,6 +69,7 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 67 - 46
packages/client/ui-user-questions/src/client/QuestionComposer.tsx

@@ -10,15 +10,10 @@ import {
   type QuestionAnswer, type QuestionComposerProps,
 } from './contract/slots.ts'
 import type { PendingQuestion } from './contract/slots.ts'
+import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts'
 import { PlanReviewPanel } from './PlanReviewPanel.tsx'
 import css from './QuestionComposer.module.css'
 
-interface DraftAnswer {
-  selected: string[]
-  custom: string
-  skipped: boolean
-}
-
 /**
  * Displayed feedback: validation feedback is stored as a dictionary KEY and
  * translated at render, so already-shown feedback follows a locale switch;
@@ -46,9 +41,9 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
   return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
 }
 
-/** The free-text answer field shared by both question shapes. */
+/** The free-text answer field shared by both question variants. */
 interface AnswerFieldProps {
-  /** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
+  /** Visual variant: the custom row's inline column or the optionless question's framed block. */
   variant: 'inline' | 'block'
   /** Current draft text. */
   value: string
@@ -79,7 +74,7 @@ interface AnswerFieldProps {
  * Mirror and textarea MUST share font, line-height, padding and wrapping rules
  * or the two heights diverge.
  *
- * @param props - field shape, draft text, and the field's event handlers.
+ * @param props - visual variant, draft text, and the field's event handlers.
  * @returns The mirrored auto-growing field.
  */
 function AnswerField(props: AnswerFieldProps) {
@@ -102,14 +97,15 @@ function AnswerField(props: AnswerFieldProps) {
 }
 
 /**
- * Composer takeover boundary; the carrier key keys local drafts, so a
- * same-request replay (same key, new carrier object) preserves them.
+ * Composer takeover router. Generic-question drafts live in this entry's
+ * Session-scoped Slot store, keyed by the pending carrier, so a strict Session
+ * entry remount restores the same request without exposing it to another one.
  *
- * One takeover, two shapes: a request that declares a presentation intent this
- * package renders takes that shape (a plan review is one decision over one
+ * One takeover, two presentations: a request that declares a presentation intent this
+ * package renders uses that presentation (a plan review is one decision over one
  * plan, not a question set), and every other request takes the generic flow.
  * The routing lives here, at the one entry that owns the composer seat, so
- * neither shape can claim a request the other is already rendering.
+ * neither presentation can claim a request the other is already rendering.
  *
  * @param props - the selector-matched pending question carrier plus the framework standard kit.
  * @returns The question flow, or the intent's own surface, for this request.
@@ -118,47 +114,74 @@ export function QuestionComposer(props: QuestionComposerProps) {
   const question = props.matched
   const review = useMemo(() => planReviewOf(question.questions), [question])
   return review === undefined
-    ? <QuestionFlow key={question.key} pending={question} t={props.t} />
+    ? (
+      <QuestionFlow
+        key={question.key}
+        pending={question}
+        t={props.t}
+        useStore={props.useStore}
+        actions={props.actions}
+      />
+    )
     : <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
 }
 
-function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
+type QuestionFlowProps =
+  { pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
+
+function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
   const questions = pending.questions
   const markdownLabels = useMemo(() => ({
     code: { copyLabel: t('copy'), copiedLabel: t('copied') },
     footnotes: t('markdown.footnotes'),
   }), [t])
-  const [index, setIndex] = useState(0)
-  const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
-    selected: [], custom: '', skipped: false,
-  })))
+  const initialProgress = useMemo<QuestionDraftProgress>(() => ({
+    index: 0,
+    drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
+  }), [questions])
+  const storedProgress = useStore(state => (
+    state.requestKey === pending.key && state.progress.drafts.length === questions.length
+      ? state.progress
+      : undefined
+  ))
+  const { index, drafts } = storedProgress ?? initialProgress
   const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
   const [error, setError] = useState<Feedback | null>(null)
   // Collapsed to the header strip so the conversation above stays readable
-  // while the user decides; the drafts survive because the state lives here.
+  // while the user decides; answer drafts live in the Session store above.
   const [minimized, setMinimized] = useState(false)
   // The free-form textarea autofocuses on first presentation; re-expanding a
   // collapsed question must not steal focus from the expand toggle back into
   // the input, so focus is granted once per question index.
   const focusedQuestions = useRef(new Set<number>())
-  // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
+  // Every navigation write stays in bounds and drafts mirrors questions 1:1.
   // oxlint-disable-next-line typescript/no-non-null-assertion
   const question = questions[index]!
   // oxlint-disable-next-line typescript/no-non-null-assertion
   const draft = drafts[index]!
   const hasOptions = (question.options?.length ?? 0) > 0
 
+  const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
+    actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts })
+  }
+
   const cancelFlow = (): void => {
     setBusy('cancel')
     setError(null)
-    void pending.cancel().catch((cause: unknown) => {
-      setBusy(null)
-      setError({ text: cause instanceof Error ? cause.message : String(cause) })
-    })
+    void pending.cancel()
+      .then(() => { actions.clear(pending.key) })
+      .catch((cause: unknown) => {
+        setBusy(null)
+        setError({ text: cause instanceof Error ? cause.message : String(cause) })
+      })
   }
 
-  const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
-    setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
+  const updateDraft = (
+    update: (current: QuestionDraftAnswer) => QuestionDraftAnswer,
+    nextIndex = index,
+  ): void => {
+    const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item)
+    replaceProgress(nextIndex, nextDrafts)
     setError(null)
   }
 
@@ -171,27 +194,24 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
         return { ...current, selected, skipped: false }
       }
       return { selected: [label], custom: '', skipped: false }
-    })
-    if (question.multiSelect !== true && index < questions.length - 1) {
-      setIndex(current => current + 1)
-    }
+    }, question.multiSelect !== true && index < questions.length - 1 ? index + 1 : index)
   }
 
-  const answered = (item: DraftAnswer): boolean =>
+  const answered = (item: QuestionDraftAnswer): boolean =>
     item.selected.length > 0 || item.custom.trim() !== ''
 
-  const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
+  const completed = (item: QuestionDraftAnswer): boolean => answered(item) || item.skipped
 
-  const submitDrafts = (values: DraftAnswer[]): void => {
+  const submitDrafts = (values: QuestionDraftAnswer[]): void => {
     const missing = values.findIndex(item => !completed(item))
     if (missing >= 0) {
-      setIndex(missing)
+      replaceProgress(missing, values)
       setError({ key: 'error.incomplete' })
       return
     }
     const answer: QuestionAnswer = {
       answers: questions.map((item, itemIndex) => {
-        const value = values[itemIndex] as DraftAnswer
+        const value = values[itemIndex] as QuestionDraftAnswer
         if (value.skipped) return { id: item.id, selected: [] }
         const custom = value.custom.trim()
         return {
@@ -203,10 +223,12 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
     }
     setBusy('answer')
     setError(null)
-    void pending.answer(answer).catch((cause: unknown) => {
-      setBusy(null)
-      setError({ text: cause instanceof Error ? cause.message : String(cause) })
-    })
+    void pending.answer(answer)
+      .then(() => { actions.clear(pending.key) })
+      .catch((cause: unknown) => {
+        setBusy(null)
+        setError({ text: cause instanceof Error ? cause.message : String(cause) })
+      })
   }
 
   const continueFlow = (): void => {
@@ -215,7 +237,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
       return
     }
     if (index < questions.length - 1) {
-      setIndex(current => current + 1)
+      replaceProgress(index + 1, drafts)
       setError(null)
       return
     }
@@ -245,10 +267,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
     const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
       ? { selected: [], custom: '', skipped: true }
       : item)
-    setDrafts(nextDrafts)
+    replaceProgress(index < questions.length - 1 ? index + 1 : index, nextDrafts)
     setError(null)
     if (index < questions.length - 1) {
-      setIndex(current => current + 1)
       return
     }
     submitDrafts(nextDrafts)
@@ -382,7 +403,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
                 <button
                   type="button" className={css.iconButton} aria-label={t('nav.prev')}
                   disabled={index === 0 || busy !== null}
-                  onClick={() => { setIndex(index - 1); setError(null) }}
+                  onClick={() => { replaceProgress(index - 1, drafts); setError(null) }}
                 >
                   <IconChevronLeftOutline14 />
                 </button>
@@ -390,7 +411,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
                 <button
                   type="button" className={css.iconButton} aria-label={t('nav.next')}
                   disabled={index === questions.length - 1 || busy !== null}
-                  onClick={() => { setIndex(index + 1); setError(null) }}
+                  onClick={() => { replaceProgress(index + 1, drafts); setError(null) }}
                 >
                   <IconChevronRightOutline14 />
                 </button>

+ 8 - 4
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -1,10 +1,11 @@
 /** Question composer props and one pending Remote waterfall response. */
-import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 // The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
   AskUserQuestionAnswer, AskUserQuestionItem,
 } from '@deepseek-ai/dsh-user-questions'
+import type { createQuestionDraftStore } from '../draft-store.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-session/client' {
   interface SessionPendingInteractionMap {
@@ -38,7 +39,7 @@ function settlePendingComposer(settle: () => void, failureMessage: string): Prom
 /**
  * A request narrowed to the `plan-review` presentation intent: everything the
  * decision card renders and answers with, so the panel never re-reads the
- * request shape. `approve` and `decline` are the asker's own options — an
+ * request fields. `approve` and `decline` are the asker's own options — an
  * answer must carry one of those labels verbatim — and `plan` is the markdown
  * body under review.
  */
@@ -108,7 +109,7 @@ function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'):
 export class PendingQuestion {
   /** Presentation discriminator used by Session pending-interaction consumers. */
   readonly kind: 'question' | 'plan-review'
-  /** Opaque render identity and local-draft remount axis. */
+  /** Opaque render identity and request key for the Session-scoped draft store. */
   readonly key: string
   /** The request's question list. */
   readonly questions: readonly AskUserQuestionItem[]
@@ -217,4 +218,7 @@ export type QuestionWait = PendingQuestion
  * whole behavior surface.
  */
 export type QuestionComposerProps =
-  PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
+  PropsRuntime<'conversation.composer'>
+  & PropsStore<ReturnType<typeof createQuestionDraftStore>>
+  & { matched: QuestionWait }
+  & PropsLocale<'question'>

+ 57 - 0
packages/client/ui-user-questions/src/client/draft-store.ts

@@ -0,0 +1,57 @@
+/**
+ * Session-scoped draft state for the generic question composer. The Slot
+ * registry owns store instances; this module exports only the factory so a
+ * plugin reload cannot reuse a module-global handle.
+ */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+
+/** One in-progress answer, including an explicit skip. */
+export interface QuestionDraftAnswer {
+  /** Offered labels currently selected. */
+  selected: string[]
+  /** Human-authored alternative or additional answer. */
+  custom: string
+  /** Whether the user explicitly skipped this question. */
+  skipped: boolean
+}
+
+/** Navigation and answer drafts for one pending request. */
+export interface QuestionDraftProgress {
+  /** Current question index. */
+  index: number
+  /** One draft per question, in request order. */
+  drafts: QuestionDraftAnswer[]
+}
+
+interface QuestionDraftState {
+  requestKey?: string
+  progress: QuestionDraftProgress
+}
+
+type QuestionDraftActions = {
+  replace: (draft: QuestionDraftState, requestKey: string, progress: QuestionDraftProgress) => void
+  clear: (draft: QuestionDraftState, requestKey: string) => void
+}
+
+const emptyProgress = (): QuestionDraftProgress => ({ index: 0, drafts: [] })
+
+/**
+ * Declare the question composer's transient Session store.
+ * @returns a non-persisted store handle whose instance is owned by the Slot registry.
+ */
+export function createQuestionDraftStore(): EngineStoreHandle<QuestionDraftState, QuestionDraftActions> {
+  return defineStore({
+    init: (): QuestionDraftState => ({ progress: emptyProgress() }),
+    actions: {
+      replace: (draft, requestKey, progress) => {
+        draft.requestKey = requestKey
+        draft.progress = progress
+      },
+      clear: (draft, requestKey) => {
+        if (draft.requestKey !== requestKey) return
+        delete draft.requestKey
+        draft.progress = emptyProgress()
+      },
+    },
+  })
+}

+ 3 - 0
packages/client/ui-user-questions/src/client/index.ts

@@ -23,6 +23,7 @@ import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-api-session-controller/client'
 import { PendingQuestion } from './contract/slots.ts'
+import { createQuestionDraftStore } from './draft-store.ts'
 import { QuestionComposer } from './QuestionComposer.tsx'
 import { en, zh, type QuestionKey } from './locales.ts'
 
@@ -86,6 +87,7 @@ async function answerQuestion(
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
+  const questionDraftStore = createQuestionDraftStore()
   const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingQuestion>(
     pending => pending.kind === 'plan-review' ? 2 : 1,
   )
@@ -95,6 +97,7 @@ export function apply(ctx: ClientContext): void {
       select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null =>
         pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
       locale: NS,
+      store: questionDraftStore,
     },
     QuestionComposer,
   ))

+ 5 - 0
packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts

@@ -6,6 +6,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { PendingQuestion } from '../src/client/contract/slots.ts'
+import { createQuestionDraftStore } from '../src/client/draft-store.ts'
 import { apply, inject } from '../src/client/index.ts'
 
 const SESSION_ID = 'session-question' as SessionId
@@ -122,6 +123,10 @@ describe('apply', () => {
     expect(entry.component).toBe(QuestionComposer)
     expect(entry.inject).toBeUndefined()
     expect(entry.locale).toBe('question')
+    const store = entry.store as ReturnType<typeof createQuestionDraftStore>
+    expect(store.create(SESSION_ID).getSnapshot()).toEqual({
+      progress: { index: 0, drafts: [] },
+    })
     const pending = b.pending.getSnapshot()[0]!
     const select = entry.select as (
       owner: { pendingInteraction: PendingQuestion | undefined },

+ 5 - 0
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -5,6 +5,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
   PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
 } from '../src/client/contract/slots.ts'
+import { createQuestionDraftStore } from '../src/client/draft-store.ts'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { en, zh } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -93,6 +94,8 @@ const inputState: InputState = {
   queue: [],
 }
 
+const questionDraftStore = createQuestionDraftStore().create(SID)
+
 /** Framework standard-kit stubs: the panel consumes only the locale seat. */
 const kit: Omit<QuestionComposerProps, 'matched'> = {
   sessionId: SID,
@@ -114,6 +117,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
     pruneImages: () => { throw new Error('unused') },
     submit: () => { throw new Error('unused') },
   },
+  useStore: selector => selector(questionDraftStore.getSnapshot()),
+  actions: questionDraftStore.actions,
   t: seatOver(zh, commonZh),
 }
 

+ 36 - 0
packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts

@@ -0,0 +1,36 @@
+/** Question-composer Session store behavior. */
+import { describe, expect, it } from 'vitest'
+import { createQuestionDraftStore, type QuestionDraftProgress } from '../src/client/draft-store.ts'
+
+const FIRST: QuestionDraftProgress = {
+  index: 1,
+  drafts: [{ selected: ['Fast'], custom: '', skipped: false }],
+}
+
+describe('createQuestionDraftStore', () => {
+  it('keeps one request progress and ignores cleanup from an obsolete request', () => {
+    const store = createQuestionDraftStore().create('session-one')
+
+    store.actions.replace('question:one', FIRST)
+    expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
+
+    store.actions.clear('question:older')
+    expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
+
+    store.actions.clear('question:one')
+    expect(store.getSnapshot()).toEqual({ progress: { index: 0, drafts: [] } })
+  })
+
+  it('replaces the previous request atomically instead of accumulating drafts', () => {
+    const store = createQuestionDraftStore().create('session-one')
+    const second: QuestionDraftProgress = {
+      index: 0,
+      drafts: [{ selected: [], custom: 'Careful', skipped: false }],
+    }
+
+    store.actions.replace('question:one', FIRST)
+    store.actions.replace('question:two', second)
+
+    expect(store.getSnapshot()).toEqual({ requestKey: 'question:two', progress: second })
+  })
+})

+ 27 - 5
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -1,8 +1,10 @@
 // @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useSyncExternalStore } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
+import { createQuestionDraftStore } from '../src/client/draft-store.ts'
 import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
 import { en, zh } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -91,10 +93,10 @@ const inputState: InputState = {
   queue: [],
 }
 
-/** Framework standard-kit stubs: the composer consumes only the locale seat;
+/** Framework standard-kit stubs: the composer consumes the locale and draft-store seats;
  *  the composed props type mandates delivery of the rest (framework hooks are
  *  plain stubs per the client testing discipline). */
-const kit: Omit<QuestionComposerProps, 'matched'> = {
+const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
   session: undefined,
   sessionId: SID,
   pendingInteraction: undefined,
@@ -118,6 +120,18 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
   t: seatOver(zh, commonZh),
 }
 
+let kit: Omit<QuestionComposerProps, 'matched'>
+
+beforeEach(() => {
+  const instance = createQuestionDraftStore().create(SID)
+  const useStore: QuestionComposerProps['useStore'] = selector => useSyncExternalStore(
+    listener => instance.subscribe(listener),
+    () => selector(instance.getSnapshot()),
+    () => selector(instance.getSnapshot()),
+  )
+  kit = { ...kitBase, useStore, actions: instance.actions }
+})
+
 const QUESTIONS: PendingQuestion['questions'] = [
   {
     id: 'profile', header: '偏好', question: '选择候选人类型',
@@ -363,13 +377,21 @@ describe('QuestionComposer', () => {
     expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
   })
 
-  it('keeps drafts when the same pending request rerenders', () => {
+  it('restores the current page and drafts after the strict Session entry remounts', () => {
     const pending = wait()
     const view = render(<QuestionComposer matched={pending.carrier} {...kit} />)
     fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
+    const custom = screen.getByPlaceholderText('输入你的答案')
+    fireEvent.change(custom, { target: { value: '保留这段草稿' } })
     expect(screen.getByText('2 / 3')).toBeTruthy()
-    view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
+
+    view.unmount()
+    render(<QuestionComposer matched={pending.carrier} {...kit} />)
+
     expect(screen.getByText('2 / 3')).toBeTruthy()
+    expect(screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案').value).toBe('保留这段草稿')
+    fireEvent.click(screen.getByLabelText('上一题'))
+    expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('true')
   })
 })
 

+ 3 - 0
packages/client/ui-user-questions/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../locale"
     },
+    {
+      "path": "../store"
+    },
     {
       "path": "../ui-conversation"
     },

+ 1 - 0
packages/core/session/src/index.ts

@@ -1152,4 +1152,5 @@ export class SessionStore extends Service {
 
 }
 
+export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts'
 export default SessionStore

+ 70 - 0
packages/core/session/src/seq-ranges.ts

@@ -0,0 +1,70 @@
+/** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
+
+/** A stored source sequence or inclusive consecutive range. */
+export type EncodedSeq = number | [number, number]
+
+function isStrictlyIncreasing(values: readonly number[]): boolean {
+  return values.every((value, index) => index === 0 || value > (values[index - 1] as number))
+}
+
+/**
+ * Replace profitable consecutive runs with inclusive pairs.
+ * @param values - validated in-memory source sequences.
+ * @returns a lossless JSON storage form.
+ */
+export function encodeSeqRanges(values: readonly number[]): EncodedSeq[] {
+  if (!isStrictlyIncreasing(values)) return [...values]
+  const encoded: EncodedSeq[] = []
+  for (let start = 0; start < values.length;) {
+    let end = start
+    while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1
+    if (end - start >= 2) encoded.push([values[start] as number, values[end] as number])
+    else for (let index = start; index <= end; index += 1) encoded.push(values[index] as number)
+    start = end + 1
+  }
+  return encoded
+}
+
+/**
+ * Expand a JSON storage-form source sequence array.
+ * @param value - parsed storage value.
+ * @param maxEntries - largest list permitted by the owning event.
+ * @returns the in-memory source sequences.
+ */
+export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): number[] {
+  if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array')
+  const decoded: number[] = []
+  let hasRange = false
+  for (const entry of value) {
+    if (typeof entry === 'number') {
+      assertSeq(entry)
+      if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence')
+      decoded.push(entry)
+      continue
+    }
+    if (!Array.isArray(entry) || entry.length !== 2) {
+      throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs')
+    }
+    const start: unknown = entry[0]
+    const end: unknown = entry[1]
+    assertSeq(start)
+    assertSeq(end)
+    if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end')
+    const length = end - start + 1
+    if (length > maxEntries - decoded.length) {
+      throw new TypeError('sourceEventSeqs range exceeds its event sequence')
+    }
+    for (let seq = start; seq <= end; seq += 1) decoded.push(seq)
+    hasRange = true
+  }
+  if (hasRange && !isStrictlyIncreasing(decoded)) {
+    throw new TypeError('sourceEventSeqs ranges must be strictly increasing')
+  }
+  return decoded
+}
+
+function assertSeq(value: unknown): asserts value is number {
+  if (!Number.isSafeInteger(value) || (value as number) < 0) {
+    throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
+  }
+}

+ 36 - 0
packages/core/session/tests/seq-ranges.spec.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { decodeSeqRanges, encodeSeqRanges } from '@deepseek-ai/dsh-session'
+
+describe('sourceEventSeqs ranges', () => {
+  it.each([
+    [],
+    [5],
+    [10, 11, 12, 13, 14],
+    [16, 17, 100, 200, 201, 202, 203],
+    [3, 2],
+    [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
+  ].map(values => [values]))('round-trips %j', (values) => {
+    expect(decodeSeqRanges(encodeSeqRanges(values))).toEqual(values)
+  })
+
+  it('encodes only profitable increasing runs', () => {
+    expect(encodeSeqRanges([1, 3, 4, 5, 7])).toEqual([1, [3, 5], 7])
+    expect(encodeSeqRanges([1, 3, 4, 7])).toEqual([1, 3, 4, 7])
+    expect(encodeSeqRanges([3, 2])).toEqual([3, 2])
+  })
+
+  it('does not impose a persistence-only provenance length limit', () => {
+    const values = Array.from({ length: 1_000_001 }, (_, index) => index)
+    expect(encodeSeqRanges(values)).toEqual([[0, 1_000_000]])
+  })
+
+  it('rejects malformed or impossible expansions', () => {
+    expect(() => decodeSeqRanges('nope')).toThrow(/must be an array/)
+    expect(() => decodeSeqRanges([-1])).toThrow(/non-negative safe integers/)
+    expect(() => decodeSeqRanges([[1]])).toThrow(/\[start, end\] pairs/)
+    expect(() => decodeSeqRanges([[4, 2]])).toThrow(/start <= end/)
+    expect(() => decodeSeqRanges([[2, 5], [4, 7]])).toThrow(/strictly increasing/)
+    expect(() => decodeSeqRanges([0], 0)).toThrow(/exceeds its event sequence/)
+    expect(() => decodeSeqRanges([[0, 10]], 10)).toThrow(/exceeds its event sequence/)
+  })
+})

+ 2 - 2
packages/experimental/webworker-packer/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/experimental/webworker-packer/README.md
-README.md: 8464c2a6e442c6f6a88d6cf87ddd97de069ea37f
-README.zh.md: c5a0c0e10c273dcd1477a906a7c2a14407034c2c
+README.md: fd4f9793b17ad66ae4c5f1038ce027b2efe43abe
+README.zh.md: 85e53445eb149fe55717eec27028f8285823af05

+ 2 - 1
packages/experimental/webworker-packer/README.md

@@ -27,7 +27,7 @@ The pack is a three-layer standard stack:
 
 1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
 2. **Publish view** — each workspace or vendored package contributes its built npm slice (`files` through picomatch) without source or workspace `dist/`. External packages retain published JavaScript under both `src/` and `dist/` because their `main` or `exports` may point there; only generic test, map, declaration, and archive exclusions apply.
-3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
+3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. The transform reports 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 import from `node:module` or `module`, including an import alias. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
 
 `repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build.
 
@@ -49,6 +49,7 @@ None; this package neither assembles nor sends a provider request.
 <a id="known-limitations-and-deferred-work"></a>
 
 - **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change.
+- **Reachability infers only exact request forms** — computed `import` and `require` arguments, stored `createRequire` results, CommonJS-obtained `createRequire`, and bases other than `import.meta.url` resolve only at runtime and fail loud if the target was otherwise pruned; a target reachable only through those forms needs an explicit image entry seed.
 - **Vendored package sources (`src/*.ts`) are excluded** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule.
 - **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first.
 

+ 2 - 1
packages/experimental/webworker-packer/README.zh.md

@@ -27,7 +27,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
 
 1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
 2. **发布视图**——每个 workspace 或 vendored 包贡献其构建后的 npm 切片(`files` 走 picomatch),不带源码和 workspace `dist/`。外部包的 `main` 或 `exports` 可能指向 `src/` 或 `dist/`,因此两处发布 JavaScript 都会保留,只应用通用的测试、map、声明与归档排除规则。
-3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
+3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。Transform 会报告具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入(含导入别名)在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
 
 `repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。
 
@@ -49,6 +49,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
 <a id="known-limitations-and-deferred-work"></a>
 
 - **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。
+- **可达性只推断精确请求形式**——计算得到的 `import` 与 `require` 参数、保存下来的 `createRequire` 结果、经 CommonJS 获取的 `createRequire`,以及基准不是 `import.meta.url` 的调用只在运行时解析;若目标已被裁掉就会立即失败。只能通过这些形式触达的目标需要显式镜像入口种子。
 - **vendored 包源码(`src/*.ts`)被排除**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。
 - **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。
 

+ 2 - 2
packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md
-README.md: 9d00ec5b56c958149eceef34de16c36741ce0565
-README.zh.md: 04039d09ed60d0cf25d851dcd9767fd80b9a7221
+README.md: eaf467b8e4475908d5534113553c51182e30b7f2
+README.zh.md: 879f9a5d2d949296886ed6af1e6bf40bd42540d5

+ 1 - 1
packages/experimental/webworker-runtime/README.md

@@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated
 Three artifacts from one tsdown pipeline:
 
 - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
-- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
+- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
 - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
 - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client.
 

+ 1 - 1
packages/experimental/webworker-runtime/README.zh.md

@@ -26,7 +26,7 @@ kind: "package-library"
 一条 tsdown 管线出三个产物:
 
 - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
-- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
+- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。
 

+ 62 - 8
packages/experimental/webworker-runtime/src/compile/transform.ts

@@ -70,6 +70,7 @@ class Transformer {
   private moduleSyntax = false
   private readonly moduleRequests = new Set<string>()
   private readonly metaResolveRequests = new Set<string>()
+  private readonly createRequireBindings = new Set<string>()
 
   constructor(source: string, private readonly path: string) {
     // A `#!` line is only legal at offset zero, and the prologue takes that spot;
@@ -154,7 +155,8 @@ class Transformer {
     if (Array.isArray(node.attributes) && node.attributes.length > 0) {
       this.fail('import attributes are not supported', node.start)
     }
-    const request = `require(${this.literal(node.source as Node)})`
+    const source = node.source as Node
+    const request = `require(${this.literal(source)})`
     const specifiers = node.specifiers as Node[]
     if (specifiers.length === 0) {
       this.replace(node.start, node.end, `${request};`)
@@ -313,7 +315,12 @@ class Transformer {
 
   // --- traversal ------------------------------------------------------------
 
-  private visit(node: unknown, context: { asyncGenerator: boolean; functionDepth: number; statement?: Node }): void {
+  private visit(node: unknown, context: {
+    asyncGenerator: boolean
+    functionDepth: number
+    moduleScope: boolean
+    statement?: Node
+  }): void {
     if (node === null || typeof node !== 'object') return
     if (Array.isArray(node)) {
       for (const child of node) this.visit(child, context)
@@ -338,11 +345,12 @@ class Transformer {
         break
       }
       case 'CallExpression': {
-        // CommonJS bodies pass through untransformed, but their literal
-        // `require()` calls are module requests all the same.
+        // CommonJS bodies pass through untransformed, but literal calls through
+        // the wrapper's `require` remain module requests. The ESM case accepts
+        // only a direct module-scope createRequire call with the importer URL.
         const callee = record.callee as Node
         const callArguments = record.arguments as Node[]
-        if (callee.type === 'Identifier' && callee.name === 'require' && callArguments.length === 1
+        if (this.isRequireCall(callee, context.moduleScope) && callArguments.length === 1
           && typeof callArguments[0]?.value === 'string') {
           this.moduleRequests.add(callArguments[0].value)
         }
@@ -382,6 +390,7 @@ class Transformer {
           if (context.functionDepth === 0) this.fail('a top-level for-await loop cannot run as CommonJS', record.start)
           this.forAwait(record)
         }
+        next = { ...next, moduleScope: false }
         break
       case 'LabeledStatement': {
         const body = record.body as Node
@@ -399,8 +408,17 @@ class Transformer {
         next = {
           asyncGenerator: record.async === true && record.generator === true,
           functionDepth: context.functionDepth + 1,
+          moduleScope: false,
         }
         break
+      case 'BlockStatement':
+      case 'CatchClause':
+      case 'ClassBody':
+      case 'ForStatement':
+      case 'ForInStatement':
+      case 'SwitchStatement':
+        next = { ...next, moduleScope: false }
+        break
       default: break
     }
     if (record.type === 'ExpressionStatement') next = { ...next, statement: record }
@@ -410,6 +428,40 @@ class Transformer {
     }
   }
 
+  private isCreateRequireCall(node: Node): boolean {
+    if (node.type !== 'CallExpression') return false
+    const callee = node.callee as Node
+    const args = node.arguments as Node[]
+    if (callee.type !== 'Identifier' || !this.createRequireBindings.has(nameOf(callee)) || args.length !== 1) {
+      return false
+    }
+    const base = args[0] as Node
+    if (base.type !== 'MemberExpression' || base.computed === true) return false
+    const object = base.object as Node
+    const property = base.property as Node
+    return object.type === 'MetaProperty'
+      && (object.meta as Node).name === 'import'
+      && property.type === 'Identifier'
+      && property.name === 'url'
+  }
+
+  private isRequireCall(callee: Node, moduleScope: boolean): boolean {
+    return (callee.type === 'Identifier' && callee.name === 'require')
+      || (moduleScope && this.isCreateRequireCall(callee))
+  }
+
+  private indexCreateRequireImports(program: Node): void {
+    for (const statement of program.body as Node[]) {
+      if (statement.type !== 'ImportDeclaration') continue
+      const source = statement.source as Node
+      if (source.value !== 'node:module' && source.value !== 'module') continue
+      for (const specifier of statement.specifiers as Node[]) {
+        if (specifier.type !== 'ImportSpecifier' || nameOf(specifier.imported as Node) !== 'createRequire') continue
+        this.createRequireBindings.add(nameOf(specifier.local as Node))
+      }
+    }
+  }
+
   run(): string {
     // Transforming a lowered body again would nest the protocol inside itself:
     // it still runs, only slower and unreadable, so a mis-wired manifest must
@@ -427,7 +479,8 @@ class Transformer {
     } catch (reason) {
       this.fail(`parse failed: ${(reason as Error).message}`, 0)
     }
-    this.visit(program, { asyncGenerator: false, functionDepth: 0 })
+    this.indexCreateRequireImports(program)
+    this.visit(program, { asyncGenerator: false, functionDepth: 0, moduleScope: true })
     if (this.edits.length === 0 && !this.moduleSyntax) return this.source
 
     const prologue: string[] = []
@@ -539,8 +592,9 @@ export interface LoweredModule {
   readonly lowered: boolean
   /**
    * Static module requests the body makes: import and re-export sources,
-   * literal dynamic imports, and literal `require()` calls. Computed requests
-   * are absent — they resolve (and fail loud) at runtime only.
+   * literal dynamic imports and calls through `require`, plus module-scope
+   * direct literal calls through an imported `createRequire(import.meta.url)`.
+   * Computed and rebased requests resolve (and fail loud) at runtime only.
    */
   readonly moduleRequests: readonly string[]
   /**

+ 5 - 1
packages/experimental/webworker-runtime/src/node/globals/process.ts

@@ -2,7 +2,8 @@
  * The `process` global the worker needs before any VFS module runs. Cordis
  * reads `process.env` and `process.versions.node` while the Loader is
  * constructed, and `cordis.yml` keeps its `!!js process.*` expressions, so the
- * configuration bytes stay identical to the Node deployment.
+ * configuration bytes stay identical to the Node deployment. Third-party Node
+ * packages use the presence of `process.title` to avoid browser-only globals.
  * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/process
  */
 import { requireActiveModuleLoader } from '../../module-system/module-loader.ts'
@@ -23,6 +24,8 @@ export interface ProcessShim {
   readonly env: Record<string, string>
   readonly argv: string[]
   readonly execArgv: string[]
+  /** Node process identity used by dependencies for environment detection. */
+  readonly title: string
   /**
    * Node 22 `process.getBuiltinModule`: the worker's module proxy for a
    * builtin id (`fs`, `node:fs`), or undefined for anything else — it never
@@ -86,6 +89,7 @@ export function installProcessGlobal(options: ProcessShimOptions): ProcessShim {
     env: { ...options.env },
     argv: [...(options.argv ?? ['node', 'dsh-webworker'])],
     execArgv: [],
+    title: 'dsh-webworker',
     platform: 'linux',
     arch: 'x64',
     pid: 1,

+ 29 - 0
packages/experimental/webworker-runtime/tests/compile/transform.spec.ts

@@ -144,6 +144,35 @@ check(
   check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n')
 }
 
+{
+  const direct = lowerModuleSource({
+    filename: 'node_modules/p/direct.js',
+    source: "import { createRequire } from 'node:module'\ncreateRequire(import.meta.url)('external-package')\n",
+  })
+  check('literal createRequire call is a module request', direct.moduleRequests, ['node:module', 'external-package'])
+
+  const aliased = lowerModuleSource({
+    filename: 'node_modules/p/aliased.js',
+    source: "makeRequire(import.meta.url)('aliased-package')\nimport { createRequire as makeRequire } from 'node:module'\n",
+  })
+  check('aliased createRequire import is indexed before traversal', aliased.moduleRequests, ['aliased-package', 'node:module'])
+
+  const runtimeOnly = lowerModuleSource({
+    filename: 'node_modules/p/runtime-only.js',
+    source: [
+      "import { createRequire } from 'node:module'",
+      'const localRequire = createRequire(import.meta.url)',
+      "localRequire('stored')",
+      "createRequire(new URL('./other.js', import.meta.url))('rebased')",
+      "{ const createRequire = () => () => undefined; createRequire(import.meta.url)('block-shadowed') }",
+      "function load(createRequire) { createRequire(import.meta.url)('parameter-shadowed') }",
+      "for (const createRequire of []) createRequire(import.meta.url)('for-of-shadowed')",
+      "switch (0) { case 0: const createRequire = () => () => undefined; createRequire(import.meta.url)('switch-shadowed') }",
+    ].join('\n'),
+  })
+  check('stored, rebased, and shadowed createRequire calls stay runtime-only', runtimeOnly.moduleRequests, ['node:module'])
+}
+
 // ---------------------------------------------------------------------------
 // 3. Import forms.
 // ---------------------------------------------------------------------------

+ 1 - 0
packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts

@@ -19,6 +19,7 @@ describe('process shim', () => {
     const shim = installProcessGlobal({ cwd: '/dsh', env: { DSH_HOME: '/dsh/home' } })
     expect(shim.cwd()).toBe('/dsh')
     expect(shim.env.DSH_HOME).toBe('/dsh/home')
+    expect(shim.title).toBe('dsh-webworker')
     // "0.0.0" keeps the vendored Loader off Node internals so the worker owns
     // the module seam.
     expect(shim.versions.node).toBe('0.0.0')

+ 80 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1558,6 +1558,86 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n      { name: \'settings.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-settings/src/client/contract/slots.ts:30',
   },
+  {
+    key: 'settings.models.footer',
+    kind: 'list',
+    scope: 'root',
+    summary: 'Ordered extension area after the provider rows and the add controls.',
+    doc: 'Ordered extension area after the provider rows and the add controls.\nWithout a registrant the area renders nothing.',
+    registerOptions: [
+      {
+        name: 'id',
+        requirement: 'required',
+        type: 'string',
+        doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
+      },
+      {
+        name: 'order',
+        requirement: 'optional',
+        type: 'number',
+        doc: 'Position among the entries, ascending (default 0).',
+      },
+      {
+        name: 'label',
+        requirement: 'optional',
+        type: 'string | (() => string)',
+        doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
+      },
+    ],
+    ownerProps: [
+      '/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n  /** Marker field: footer owner props are intentionally empty. */\n  children?: never\n}',
+    ],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
+    occupants: [],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.models.footer\', () => ctx.slots.register(\n      { name: \'settings.models.footer\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:38',
+  },
+  {
+    key: 'settings.models.provider-card',
+    kind: 'keyed',
+    scope: 'root',
+    summary: 'One provider card\'s adapter extension area, dispatched with `entryKey = settingsNs` on every card that renders a directory row: a saved row\'s card (its first-run setup posture included) and the add-provider draft card.',
+    doc: 'One provider card\'s adapter extension area, dispatched with\n`entryKey = settingsNs` on every card that renders a directory row: a\nsaved row\'s card (its first-run setup posture included) and the\nadd-provider draft card. The hand-declared draft card has no directory\nrow yet, so it dispatches nothing until saved. Without a registrant the\narea renders nothing.',
+    registerOptions: [
+      {
+        name: 'key',
+        requirement: 'required',
+        type: 'string',
+        doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
+      },
+    ],
+    ownerProps: [
+      '/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n  /** The card\'s directory row (route id, display name, settings address, live state). */\n  provider: ConfigurableProviderView\n  /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n  configured: boolean\n  /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n  keyConfigured: boolean\n}',
+    ],
+    ownerPropsReferences: [
+      'ConfigurableProviderView',
+    ],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+    ],
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'settings.section\' (client-ui-settings-models), so it exists while that entry is mounted',
+    occupants: [],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.models.provider-card\', () => ctx.slots.register(\n      { name: \'settings.models.provider-card\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-settings-models/src/client/slot-contract.ts:33',
+  },
   {
     key: 'settings.onboarding',
     kind: 'list',

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

@@ -3434,7 +3434,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ClientArtifactBaseline',
-    declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n    readonly mapMtimeMs: number | null;\n    readonly mapSize: number | null;\n}',
+    declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n}',
   },
   {
     name: 'CodeBindingErrorClass',

+ 25 - 1
packages/llm/llm-pi-ai/src/auth.ts

@@ -37,6 +37,30 @@ export function recordKeyFor(providerId: string): CredentialKey {
   return credentialKey(RECORD_SCOPE, providerId)
 }
 
+/**
+ * The JSON image of one grant payload: plain objects lose their
+ * explicitly-undefined members and array entries JSON cannot hold become
+ * null, exactly as `JSON.stringify` would render them. pi-ai credentials
+ * idiomatically carry optional members as explicit `undefined` (a github.com
+ * Copilot grant holds `enterpriseUrl: undefined`), which the credential
+ * store's strict validator refuses as unrepresentable. Everything else —
+ * non-finite numbers and foreign prototypes included — passes through
+ * untouched, so a genuinely unstorable value still fails loud at the store.
+ * @param value - the value to render.
+ * @returns the value's JSON image.
+ */
+function jsonImage(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(entry => entry === undefined ? null : jsonImage(entry))
+  if (typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
+    const image: Record<string, unknown> = {}
+    for (const [key, member] of Object.entries(value)) {
+      if (member !== undefined) image[key] = jsonImage(member)
+    }
+    return image
+  }
+  return value
+}
+
 /**
  * Translate a stored record into the credential pi-ai expects.
  *
@@ -72,7 +96,7 @@ function toRecord(credential: Credential): CredentialRecord {
       ...credential.env === undefined ? {} : { env: { ...credential.env } },
     }
   }
-  return { kind: 'grant', payload: credential }
+  return { kind: 'grant', payload: jsonImage(credential) }
 }
 
 /**

+ 41 - 0
packages/llm/llm-pi-ai/tests/auth.spec.ts

@@ -64,6 +64,47 @@ describe('pi-ai credential store over harness records', () => {
     await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted })
   })
 
+  it('stores the JSON image of a grant, dropping explicitly-undefined members', async () => {
+    const ctx = await stored()
+    const store = credentialStoreFrom(ctx)
+    // The github.com Copilot shape: pi-ai sets optional members to explicit
+    // undefined, which the strict record validator refuses verbatim.
+    const granted = {
+      type: 'oauth' as const,
+      access: 'at',
+      refresh: 'rt',
+      expires: 42,
+      enterpriseUrl: undefined,
+      nested: { keep: 'x', drop: undefined },
+      list: ['a', undefined, 'b'],
+    }
+
+    await store.modify('github-copilot', () => Promise.resolve(granted))
+
+    await expect(ctx.credentials.readRecord(recordKeyFor('github-copilot'))).resolves.toEqual({
+      kind: 'grant',
+      payload: {
+        type: 'oauth',
+        access: 'at',
+        refresh: 'rt',
+        expires: 42,
+        nested: { keep: 'x' },
+        list: ['a', null, 'b'],
+      },
+    })
+  })
+
+  it('passes a genuinely unstorable grant value through to the store\'s loud refusal', async () => {
+    const ctx = await stored()
+    const store = credentialStoreFrom(ctx)
+    // A foreign-prototype member is not the undefined idiom: the image leaves
+    // it untouched and the record validator still refuses the write.
+    const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, issued: new Date(0) }
+
+    await expect(store.modify('github-copilot', () => Promise.resolve(granted)))
+      .rejects.toThrow(/JSON cannot represent/)
+  })
+
   it('shows the mutation the current credential and leaves it alone when declined', async () => {
     const store = credentialStoreFrom(await stored())
     await store.modify('openai-codex', () =>

+ 2 - 2
packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md
-README.md: cd0e9fefaed6260f890efd07160dbc264b915c9a
-README.zh.md: 8be8571bc7424621d4ecb940a8e651c8b5f73e6e
+README.md: 69fb2901d783c327878cd37570aec730a3ca0841
+README.zh.md: 188982028374d4ca11a5a658acabce5fe5df9930

+ 2 - 2
packages/session/session-persistence-jsonl/README.md

@@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### On-disk layout
 
-Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run):
+Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run). Storage records use the lossless provenance representation described below:
 
 ```text
 <root>/
@@ -90,7 +90,7 @@ The backend is a thin storage layer over the shared [PersistenceCoordinator](../
 
 ### Physical encoding
 
-The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). Listing reads and validates only the header frame. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically.
+The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). `sourceEventSeqs` uses a lossless storage representation: consecutive runs of at least three sequence numbers become `[start, end]` pairs, any other list stays verbatim, and reading expands the exact in-memory array. Listing reads and validates only the header frame. `compression: 'none'` keeps the same storage-form logical lines without frame compression. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically.
 
 ### Source map
 

+ 2 - 2
packages/session/session-persistence-jsonl/README.zh.md

@@ -54,7 +54,7 @@ kind: "package-reference"
 
 ### 磁盘布局
 
-每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行):
+每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。存储记录使用下文所述的无损来源序列表示:
 
 ```text
 <root>/
@@ -90,7 +90,7 @@ kind: "package-reference"
 
 ### 物理编码
 
-默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。列表只读取并验证 header 帧。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。
+默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。`sourceEventSeqs` 使用无损存储形式:至少包含三个序列号的连续段会变成 `[start, end]` 区间对,其他列表原样保留;读取时会展开回精确的内存数组。列表只读取并验证 header 帧。`compression: 'none'` 保留相同的存储形式逻辑行,但不使用帧压缩。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。
 
 ### 源码地图
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff