فهرست منبع

Merge remote-tracking branch 'origin/master' into feat/plugin-mgmt-4-web

# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/subsystems/boot.i18n.yaml
#	docs/subsystems/boot.md
#	docs/subsystems/boot.zh.md
#	packages/api/remotes/src/client/index.ts
#	packages/boot/plugin-manager/README.i18n.yaml
#	packages/boot/plugin-manager/README.md
#	packages/boot/plugin-manager/README.zh.md
#	packages/boot/plugin-manager/src/index.ts
#	packages/boot/plugin-manager/src/types.ts
#	packages/boot/plugin-manager/tests/manager.spec.ts
#	packages/client/ui-settings-plugin-inventory/src/client/locales.ts
#	packages/client/ui-settings-plugin-inventory/src/client/management.tsx
#	packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/verify-default-product-isolation.spec.ts
Yichen Jiang 1 ماه پیش
والد
کامیت
075aab54a7
100فایلهای تغییر یافته به همراه1158 افزوده شده و 421 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 7 6
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 8 6
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
  5. 10 12
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
  6. 10 12
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  8. 3 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  9. 3 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.i18n.yaml
  11. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.md
  12. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.zh.md
  13. 6 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.i18n.yaml
  14. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md
  15. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.zh.md
  16. 2 0
      apps/web/tests/hmr-live.e2e.ts
  17. 2 2
      docs/api-gateway.i18n.yaml
  18. 2 2
      docs/api-gateway.md
  19. 2 2
      docs/api-gateway.zh.md
  20. 2 2
      docs/config-catalog.i18n.yaml
  21. 1 3
      docs/config-catalog.md
  22. 23 25
      docs/config-catalog.zh.md
  23. 2 2
      docs/event-producer-consumer.i18n.yaml
  24. 3 3
      docs/event-producer-consumer.md
  25. 3 3
      docs/event-producer-consumer.zh.md
  26. 2 2
      docs/module-graph.i18n.yaml
  27. 1 3
      docs/module-graph.md
  28. 1 3
      docs/module-graph.zh.md
  29. 2 2
      docs/subsystems/boot.i18n.yaml
  30. 5 8
      docs/subsystems/boot.md
  31. 5 8
      docs/subsystems/boot.zh.md
  32. 2 2
      docs/subsystems/client-modules.i18n.yaml
  33. 2 2
      docs/subsystems/client-modules.md
  34. 2 2
      docs/subsystems/client-modules.zh.md
  35. 2 2
      docs/tool-catalog.i18n.yaml
  36. 7 0
      docs/tool-catalog.md
  37. 7 0
      docs/tool-catalog.zh.md
  38. 2 2
      packages/api/gateway/README.i18n.yaml
  39. 5 5
      packages/api/gateway/README.md
  40. 5 5
      packages/api/gateway/README.zh.md
  41. 4 18
      packages/api/gateway/src/client/index.ts
  42. 10 7
      packages/api/gateway/tests/gateway.client.spec.ts
  43. 4 9
      packages/api/remotes/tests/built-lib.e2e.ts
  44. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  45. 4 16
      packages/boot/plugin-manager/README.md
  46. 4 16
      packages/boot/plugin-manager/README.zh.md
  47. 0 8
      packages/boot/plugin-manager/package.json
  48. 50 0
      packages/boot/plugin-manager/src/build-approval.ts
  49. 10 0
      packages/boot/plugin-manager/src/failure.ts
  50. 19 70
      packages/boot/plugin-manager/src/index.ts
  51. 5 1
      packages/boot/plugin-manager/src/tools.ts
  52. 7 1
      packages/boot/plugin-manager/src/types.ts
  53. 69 0
      packages/boot/plugin-manager/tests/build-approval.spec.ts
  54. 78 36
      packages/boot/plugin-manager/tests/manager.spec.ts
  55. 2 0
      packages/boot/plugin-manager/tests/tools.spec.ts
  56. 1 0
      packages/client/AGENTS.md
  57. 2 2
      packages/client/hmr/README.i18n.yaml
  58. 2 2
      packages/client/hmr/README.md
  59. 2 2
      packages/client/hmr/README.zh.md
  60. 5 4
      packages/client/hmr/src/index.ts
  61. 2 2
      packages/client/modules/README.i18n.yaml
  62. 3 3
      packages/client/modules/README.md
  63. 3 3
      packages/client/modules/README.zh.md
  64. 17 7
      packages/client/modules/src/client/manifest.ts
  65. 87 18
      packages/client/modules/src/client/system.ts
  66. 73 9
      packages/client/modules/src/index.ts
  67. 159 0
      packages/client/modules/tests/loader.client.spec.ts
  68. 59 1
      packages/client/modules/tests/node-half.client.spec.ts
  69. 52 7
      packages/client/tsdown.client.ts
  70. 2 0
      packages/client/ui-plugin-manager/src/client/locales.ts
  71. 1 0
      packages/client/ui-plugin-manager/src/client/presentation.ts
  72. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  73. 2 0
      packages/client/ui-settings-plugin-inventory/README.md
  74. 2 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  75. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  76. 2 1
      packages/client/ui-sidebar-documentpreview/README.md
  77. 2 1
      packages/client/ui-sidebar-documentpreview/README.zh.md
  78. 1 0
      packages/client/ui-sidebar-documentpreview/package.json
  79. 18 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx
  80. 14 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  81. 3 2
      packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts
  82. 8 5
      packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx
  83. 0 3
      packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts
  84. 2 2
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  85. 1 1
      packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx
  86. 14 3
      packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts
  87. 17 4
      packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts
  88. 3 2
      packages/client/ui-sidebar-documentpreview/tsdown.config.ts
  89. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  90. 2 1
      packages/client/ui-sidebar-terminal/README.md
  91. 2 1
      packages/client/ui-sidebar-terminal/README.zh.md
  92. 1 0
      packages/client/ui-sidebar-terminal/package.json
  93. 14 0
      packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx
  94. 2 0
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css
  95. 1 1
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx
  96. 2 2
      packages/client/ui-sidebar-terminal/src/client/index.ts
  97. 0 2
      packages/client/ui-sidebar-terminal/src/client/terminal.module.css
  98. 5 3
      packages/client/ui-sidebar-terminal/src/client/terminal.tsx
  99. 13 3
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  100. 22 0
      packages/client/ui-sidebar-terminal/tests/bundle-split.client.spec.ts

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

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 7 - 6
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


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

@@ -26,7 +26,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 ### 包成员与模块请求
 
-[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
+[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row;每个包都有一个普通 `lib/client.js` factory bundle,还可有编译器生成的 `lib/client.<name>.js` chunk。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
 
 Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他每个动态图 row 都归属一个 application combo 脚本;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
 
@@ -34,17 +34,19 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
 
 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
 
-`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃非 bootstrap factory 与记录,下次到达即重新加载)。
+`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——入口调用 `window.__ModuleLoader__.load({ id, factory })`,chunk 还会提供其生成文件名——此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。可调用的 `require` 解析同步模块表请求;其 `require.async` 操作返回 Promise,负责加载、登记并物化一个包内 chunk。共享 tsdown 预设把源码中针对包内 chunk 的 `import()` 表达式编译到这个独立操作。受支持的产物必须自包含:入口或 chunk 不能同步 require 另一个相对 `client*.js` 产物。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(推进 owner 代次并丢弃非 bootstrap 包的入口和 chunk factory 及记录,让下次到达重新加载它们)。在旧代次捕获的 chunk 请求不能填充新代次。
 
 vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本只包含 `client.js` 资源,并由这些 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。Host 不扫描同级文件,也不把 chunk 加入启动 combo。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
-共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
+一次 `require.async("./client.<name>.js")` 调用会请求精确的带 revision URL:`/plugins/<package>/client.<name>.js?rev=<rev>`。Host 只在收到请求时读取该文件,把文件已有的 chunk 登记包装为一个脚本响应,并按 URL 缓存响应。并发调用共享一个进行中的脚本任务;成功结算要求 chunk 已登记,随后 loader 才会物化并记忆化其 exports。未知名称、缺失文件以及不同于所属 graph row 的 revision 都返回 404。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+共享 tsdown 预设为每个插件入口与 chunk 产出 map,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 combo map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。chunk 脚本同样指向自己的 map URL。只有在这些 map URL 收到 `GET` 后,source map 文件才会被读取和组合;`HEAD` 不会物化脚本或 map body。Vite 壳同样产出 sourcemap,使壳代码与经外部加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
+
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。共享预设会在每个包输出写完后标记入口。watcher 观察到该构建完成标记后,`rebuilt(id)` 会把入口字节与其时间戳一起哈希并发布所得 revision;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -78,7 +80,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 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)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 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。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制生成新 revision。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime 或 size 变化、或 row 处于标脏状态时,`rebuilt(id)` 从入口字节与构建完成时间戳派生 revision。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
 
 浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md
-2026-08-02-typert-remote-method-calls.md: b6551e1c7f8c94fb02a788aa62cb4acef1addffe
-2026-08-02-typert-remote-method-calls.zh.md: 058ec47e6749ee7576fd84fdcacfda350eec3fb8
+2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb
+2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf

+ 10 - 12
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md

@@ -31,7 +31,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
 | `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
 | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
 | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
-| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results |
+| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, and invokes methods |
 | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel |
 | API Gateway's Client face | `ctx.remote`, `ctx.remote.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` child Service, and delegates canonical calls to `ctx.connection.rpc` |
 | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations |
@@ -147,9 +147,9 @@ The strict generator writes `scope` only when a direct method has exactly one lo
 
 Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults.
 
-A LIB codec contains a success-cached Zod schema factory and a canonical `typeSymbol` consisting of "package + public subpath + export name." Host and Client gateways invoke the factory only when that boundary first encodes or decodes a value. An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same Typert model and symbol keys.
+A LIB codec contains a success-cached Zod schema factory and a canonical `typeSymbol` consisting of "package + public subpath + export name." The Host Gateway invokes parameter and identity factories when it first decodes strict input. The Client contribution retains the same codec metadata for strict input checks at mount but does not materialize invocation schemas; [Host-only Remote input validation](../simplification/2026-09-15-host-only-remote-input-validation.md) owns this placement. An SRC codec is marked only as `src-json`.
 
-Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result.
+Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Client uses its descriptor to map positional arguments and Context identity into named fields. The Host uses its descriptor to validate those fields, resolve the receiver, and invoke the method.
 
 ## Typert runtime registry
 
@@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re
 
 Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition.
 
-Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
+Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
 
 Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names.
 
@@ -315,7 +315,7 @@ Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, n
 
 `ctx.remote.$mount()` registers a contribution with `Typert.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately.
 
-The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.<namespace>` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently.
+The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.<namespace>` child Service. The function checks positional arity, constructs named `args` in descriptor parameter order without runtime type parsing, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently.
 
 Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.<namespace>`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call.
 
@@ -380,10 +380,9 @@ ctx.typertGateway.invoke({ namespace, method, args, signal })
 → direct 使用原 Service;context 先解析 scoped Context 和 Service
 → cancellation descriptor 存在时把 signal 追加到业务参数末尾
 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
-→ result codec 编码业务结果
 ```
 
-`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport.
+`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns the business result without runtime output decoding or raises a Gateway error that the Connection RPC adapter maps for transport.
 
 ## The shared `/api` call chain
 
@@ -426,7 +425,7 @@ The complete path is:
 
 ```text
 ctx.remote.goals.create(sessionId, request, signal?)
-→ Client InvocationDescriptor 编码 { args: { agentId, request } }
+→ Client InvocationDescriptor 组装 { args: { agentId, request } }
 → Client 合并 caller signal 与 contribution mount lifetime
 → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
 → Connection 创建 rpcId 和既有 client-request envelope
@@ -435,9 +434,8 @@ ctx.remote.goals.create(sessionId, request, signal?)
 → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
 → Typert interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
 → Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
-→ result codec 编码
 → Connection 写入既有 RPC result 并回送相同 rpcId
-→ Client result codec 验证并返回 CreateGoalResult
+→ Client 直接返回 CreateGoalResult
 ```
 
 Remote does not define a second-layer `{ ok, value/error }` response on the wire. Successful values and failures use the existing RPC response's `result` directly, and the failure branch carries the shared `{ code, message, details }` data. Owners, resolvers, and the Gateway all raise one class, `RemoteError`, whose code comes from the merged `RemoteErrorDetailsMap`: the Host encodes a structurally identified `RemoteError` onto the wire unchanged — including the Gateway's own `gateway/*` assembly codes and a resolver's `session/not-found` or `session/agent-busy` — and folds only an unclassified throw into `gateway/internal`, keeping its diagnostic in the message. The Client face rebuilds an instance for the `RemoteResult` error branch, so `throw result.error` keeps throw semantics. [The failure-vocabulary Agent Note](2026-08-28-ctx-remote-failure-vocabulary.md) owns the code table, its ownership rules, and why discrimination reads `code` instead of `instanceof`.
@@ -455,7 +453,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection
 - `@deepseek-ai/dsh-typert-protocol`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors.
 - Typert generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information.
 - Typert runtime: separately stores the current environment's local reflection and imported Remote contributions.
-- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
+- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, validates input, performs lookup, resolves Context receivers, invokes methods, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
 - `@deepseek-ai/dsh-api-remotes`: the BFF layer; registers the application's forwarded Cordis event source and the Host home carried by generation readiness, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract.
 - Connection: owns the single HTTP Server/future WebSocket carrier, the shared `/api` route and its composite FetchHandler, owner-registered exact Fetch routes, the RPC envelope, rpcId, serialization, trust, and error transport.
 - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries.
@@ -516,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma
 
 Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error.
 
-Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior.
+Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms.
 
 A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime.
 

+ 10 - 12
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md

@@ -31,7 +31,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
 | `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
 | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
 | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
-| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 |
+| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver 并调用方法 |
 | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route |
 | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
 | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
@@ -147,9 +147,9 @@ InvocationDescriptor {
 
 参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。
 
-LIB codec 带有只缓存成功结果的 Zod schema factory 和「package + 公共 subpath + export name」的规范 `typeSymbol`;Host 与 Client gateway 只在该边界首次编码或解码值时调用 factory。SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 Typert 模型和 symbol key 生成。
+LIB codec 带有只缓存成功结果的 Zod schema factory 和「package + 公共 subpath + export name」的规范 `typeSymbol`。Host Gateway 首次解码严格输入时调用参数与身份 factory。Client contribution 保留同一 codec 元数据,以在挂载时检查严格输入,但不实例化调用 schema;[仅在 Host 校验 Remote 输入](../simplification/2026-09-15-host-only-remote-input-validation.zh.md)规定了这个位置。SRC codec 只标记 `src-json`。
 
-descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。
+descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload。Client 用自己的 descriptor 把位置参数和 Context identity 映射为具名字段;Host 用自己的 descriptor 校验这些字段、解析 receiver 并调用方法。
 
 ## Typert 运行时 registry
 
@@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/
 
 Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。
 
-Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
+Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
 
 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。
 
@@ -315,7 +315,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依
 
 `ctx.remote.$mount()` 把 contribution 注册到 `Typert.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。
 
-Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.<namespace>` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。
+Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.<namespace>` 子 Service 上的真实函数。函数检查位置参数数量,按 descriptor 的参数顺序构造具名 `args`,不做运行时类型解析,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。
 
 带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.<namespace>` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。
 
@@ -380,10 +380,9 @@ ctx.typertGateway.invoke({ namespace, method, args, signal })
 → direct 使用原 Service;context 先解析 scoped Context 和 Service
 → cancellation descriptor 存在时把 signal 追加到业务参数末尾
 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
-→ result codec 编码业务结果
 ```
 
-`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。
+`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它直接返回未经运行时输出解码的业务结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。
 
 ## 共享 `/api` 调用链
 
@@ -426,7 +425,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `
 
 ```text
 ctx.remote.goals.create(sessionId, request, signal?)
-→ Client InvocationDescriptor 编码 { args: { agentId, request } }
+→ Client InvocationDescriptor 组装 { args: { agentId, request } }
 → Client 合并 caller signal 与 contribution mount lifetime
 → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
 → Connection 创建 rpcId 和既有 client-request envelope
@@ -435,9 +434,8 @@ ctx.remote.goals.create(sessionId, request, signal?)
 → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
 → Typert interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
 → Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
-→ result codec 编码
 → Connection 写入既有 RPC result 并回送相同 rpcId
-→ Client result codec 验证并返回 CreateGoalResult
+→ Client 直接返回 CreateGoalResult
 ```
 
 Remote 不在 wire 上定义第二层 `{ ok, value/error }` response。成功值与失败都直接使用既有 RPC response 的 `result`,失败分支携带共享的 `{ code, message, details }` 数据。owner、resolver 与 Gateway 抛的都是同一个类 `RemoteError`,其码来自合并后的 `RemoteErrorDetailsMap`:Host 把结构识别出的 `RemoteError` 原样编码上 wire——包括 Gateway 自己的 `gateway/*` 装配码,以及 resolver 的 `session/not-found`、`session/agent-busy`——只把未归类的 throw 折成 `gateway/internal`,并把诊断串留在 message 里。Client face 为 `RemoteResult` 的错误分支重建实例,因此 `throw result.error` 的 throw 语义成立。[失败词汇 Agent Note](2026-08-28-ctx-remote-failure-vocabulary.zh.md) 持有码表、落点规则,以及为什么判别读 `code` 而不用 `instanceof`。
@@ -455,7 +453,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H
 - `@deepseek-ai/dsh-typert-protocol`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。
 - Typert generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。
 - Typert runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。
-- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
+- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,校验输入,执行 lookup、解析 Context receiver、调用方法,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
 - `@deepseek-ai/dsh-api-remotes`:BFF 层;注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host home,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。
 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与其复合 FetchHandler、各 owner 注册的精确 Fetch route、RPC envelope、rpcId、序列化、trust 和错误传输。
 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。
@@ -516,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
 
 类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。
 
-Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。
+生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。
 
 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: 54654a81e5e9a36a99b7d3013e2c1d622f575391
-2026-09-14-current-profile-plugin-management.zh.md: 58659ef2b1e6b73991b913c19aaf07032db6d78e
+2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a
+2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -16,10 +16,12 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
 
 Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
 
-This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
+This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 
 CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
 
+Build approvals update pnpm 11's unresolved `allowBuilds` entries under the same profile lock and preserve unrelated YAML. They persist by exact package name rather than applying an unrestricted script policy. The retry accepts only names still pending, so stale requests cannot override a subsequent denial. Package cleanup leaves the approval settings intact; a later retry can use them without retaining partially installed dependencies. The service reports policy-only changes. Agent tools may grant approval on the user's behalf; their instructions require explicit conversational consent, while the service validates only pending package names. Approval rejects anchors and aliases inside `allowBuilds` to prevent shared YAML nodes from changing unrequested permissions.
+
 ## Alternatives considered
 
 **Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -16,10 +16,12 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
 
-这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 
 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
 
+构建审批在同一个 profile 写锁内更新 pnpm 11 尚未决定的 `allowBuilds` 条目,并保留无关 YAML。授权按准确包名持久化,不采用无条件允许脚本的策略。重试只接受仍在待审批列表中的包名,因此过期请求不能覆盖后续拒绝。包清理保留审批设置,后续重试可以复用授权而不必保留部分安装的依赖。service 报告仅涉及策略的变化。Agent 工具可以代表用户授权;工具说明要求用户在对话中明确同意,而 service 仅验证待审批包名。审批拒绝 `allowBuilds` 内的锚点和别名,避免共享 YAML 节点改变未请求的权限。
+
 ## 考虑过的替代方案
 
 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.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-09-15-narrow-pi-ai-runtime-imports.md
+2026-09-15-narrow-pi-ai-runtime-imports.md: 307d880f3fce22b0e1b96efb9c3a220a511eff15
+2026-09-15-narrow-pi-ai-runtime-imports.zh.md: 139983446a2f0cd5315629c4e0a632f8c125a837

+ 25 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.md

@@ -0,0 +1,25 @@
+# Agent Note: Narrow pi-ai runtime imports
+
+Status: implemented
+
+English | [中文](2026-09-15-narrow-pi-ai-runtime-imports.zh.md)
+
+## Problem
+
+The base bundle mounts `dsh-llm-pi-ai` with no configured routes so the Models settings page can offer pi-ai providers. Importing pi-ai's aggregate entry point for model helpers also evaluates its exported TypeBox namespace, adding hundreds of modules to every application startup even when all Sessions use `dsh-llm-deepseek`.
+
+## Decision
+
+`dsh-llm-pi-ai` has no runtime import of pi-ai's aggregate entry point. Catalog and login metadata continue through `providers/all`; protocol implementations use their existing `api/*.lazy` entries; overflow detection uses `utils/overflow`. Package-local `models.ts` supplies the three model helpers the adapter needs. Its collection comes from pi-ai's public `builtinModels()` implementation and is cleared before route providers are installed. Its provider constructor implements the static single-protocol case this adapter supplies. Its reasoning-level selection reads pi-ai's public `Model` metadata in pi-ai's escalation order.
+
+Type-only imports from the aggregate entry point remain because TypeScript erases them. Import profiling of the built package resolves 153 pi-ai modules and no TypeBox modules or pi-ai aggregate entry.
+
+## Alternatives considered
+
+- **Add a pi-ai `models` export.** Rejected because this package does not need an upstream export-map change to consume the public provider, API, utility, and model metadata interfaces already available.
+- **Dynamically import the aggregate entry point.** Rejected because the dormant adapter needs none of it; excluding the entry entirely removes the work instead of moving it to a later operation.
+- **Copy pi-ai's complete Models implementation.** Rejected because `builtinModels()` already returns the upstream implementation with its authentication and storage behavior. Clearing its providers preserves that implementation without maintaining a fork.
+
+## Consequences
+
+Applications still load `providers/all` so configuration and authorization surfaces retain the complete installed provider directory. Constructing an adapter snapshot briefly constructs and then clears the built-in provider set before installing the resolved route providers. The package-local provider constructor deliberately accepts only static models and one protocol implementation; adding dynamic models, filtering, or multi-protocol custom routes requires extending that local function together with its tests.

+ 25 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.zh.md

@@ -0,0 +1,25 @@
+# Agent Note:收窄 pi-ai 运行时 import
+
+Status: implemented
+
+[English](2026-09-15-narrow-pi-ai-runtime-imports.md) | 中文
+
+## 问题
+
+基础 bundle 会在没有配置路由时挂载 `dsh-llm-pi-ai`,让 Models 设置页能够提供 pi-ai provider。为了 model helper 而 import pi-ai 聚合入口还会求值其导出的 TypeBox namespace,即使所有 Session 都使用 `dsh-llm-deepseek`,每次应用启动也会额外加载数百个模块。
+
+## 决策
+
+`dsh-llm-pi-ai` 不再运行时 import pi-ai 聚合入口。Catalog 与登录元数据继续使用 `providers/all`;协议实现继续使用现有 `api/*.lazy` 入口;overflow 检测使用 `utils/overflow`。包内 `models.ts` 提供适配器所需的三个 model helper。Collection 来自 pi-ai 公开的 `builtinModels()` 实现,并在安装路由 provider 前清空。Provider constructor 实现本适配器传入的静态单协议分支。Reasoning level 选择按照 pi-ai 的升级顺序读取其公开 `Model` 元数据。
+
+聚合入口的 type-only import 会被 TypeScript 擦除,因此予以保留。构建产物的 import profile 会解析 153 个 pi-ai 模块,不包含 TypeBox 模块或 pi-ai 聚合入口。
+
+## 考虑过的替代方案
+
+- **给 pi-ai 增加 `models` export。** 拒绝,因为本包可以直接使用已经公开的 provider、API、utility 与 model metadata interface,无需修改上游 export map。
+- **动态 import 聚合入口。** 拒绝,因为休眠适配器不需要其中任何内容;完全排除该入口会直接删除工作,而不是把工作移动到之后的操作。
+- **复制 pi-ai 完整的 Models 实现。** 拒绝,因为 `builtinModels()` 已经返回包含其认证与存储行为的上游实现。清空其中的 provider 可以保留该实现,无需维护 fork。
+
+## 后果
+
+应用仍会加载 `providers/all`,因此配置与授权界面保留完整的已安装 provider 目录。构建 adapter snapshot 时会短暂构造并清空内置 provider set,再安装已解析的路由 provider。包内 provider constructor 只接受静态 model 和一个协议实现;若要增加动态 model、filter 或多协议自定义路由,必须同时扩展该本地函数及其测试。

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.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/simplification/2026-09-15-host-only-remote-input-validation.md
+2026-09-15-host-only-remote-input-validation.md: 5c256fb5f5b78b646c645a44b77718fa40a62399
+2026-09-15-host-only-remote-input-validation.zh.md: 92a3f2415c6206a0513d8338bbb8d0844bf5d43d

+ 30 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md

@@ -0,0 +1,30 @@
+# Agent Note: Validate Remote inputs only on the Host
+
+Status: implemented
+
+English | [中文](2026-09-15-host-only-remote-input-validation.zh.md)
+
+## Problem
+
+Generated Client Remote methods expose TypeScript signatures and forward calls through Connection to a Host Gateway that already checks exact argument fields, executes each strict input codec, and verifies JSON data before lookup or business invocation. Executing the corresponding schema for every Client argument duplicates this validation, materializes otherwise lazy Zod schemas in the Client, and gives invalid JavaScript calls a different failure path depending on which side rejects them first.
+
+The Client still needs descriptor metadata to check positional arity, map values to named wire fields, bind a scoped Context identity, omit an explicitly undefined optional value, and combine cancellation. None of these operations requires executing a runtime schema.
+
+## Decision
+
+Client Remote validates descriptor integrity when a contribution mounts, then forwards typed arguments and bound Context identities without calling invocation codec factories. It continues to reject wrong positional arity and missing Client Context bindings locally. Successful unary results and stream items also pass through without Client-side type parsing.
+
+The Host Gateway owns runtime input validation. It checks the exact named fields, executes strict parameter and identity codecs, verifies JSON values, and completes lookup before invoking business code. A JavaScript caller that bypasses the generated TypeScript API receives the Host's `gateway/input-invalid` result when the request reaches the Host; a value that cannot enter the carrier may instead fail serialization.
+
+Generated Remote contributions retain codec metadata because `InvocationDescriptor` remains shared between Host and Client artifacts and Client mounting still requires strict codecs for every Client-supplied field. The broader Remote architecture remains in [Typert-generated Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md); this decision supersedes only its Client-side invocation codec execution.
+
+## Alternatives considered
+
+- **Keep Client and Host input parsing.** This gives a malformed JavaScript caller an earlier local error and strips undeclared object properties before transport, but every valid call pays for duplicate schema materialization and parsing even though the Host must validate independently.
+- **Remove codec metadata from Remote Client artifacts.** This could reduce generated Client code further, but it changes the shared descriptor and generator protocol. Keeping lazy factories preserves strict contribution checks without paying runtime schema construction cost.
+
+## Consequences
+
+Normal Client calls allocate no invocation schema and perform no duplicate Zod parse. Host validation remains the authority before lookup and business execution, while Client code retains arity, Context binding, cancellation, and contribution-lifecycle failures.
+
+Malformed runtime values fail later than before. Undeclared object properties may cross the trusted carrier before the Host codec removes them, so callers that derive requests from untrusted or secret-bearing objects must construct the declared DTO rather than relying on Client parsing as a redaction step. Client tests pin unchanged forwarding, and Host tests pin strict and JSON input rejection.

+ 30 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.zh.md

@@ -0,0 +1,30 @@
+# Agent Note: 仅在 Host 校验 Remote 输入
+
+Status: implemented
+
+[English](2026-09-15-host-only-remote-input-validation.md) | 中文
+
+## Problem
+
+生成的 Client Remote 方法公开 TypeScript 签名,并通过 Connection 把调用转给 Host Gateway;Host Gateway 已经在 lookup 或业务调用前检查精确参数字段、执行每个严格输入 codec,并验证 JSON 数据。Client 再为每个参数执行对应 schema 会重复这次校验,在 Client 中实例化原本惰性创建的 Zod schema,还会让无效 JavaScript 调用根据哪一侧先拒绝而走不同的失败路径。
+
+Client 仍需要 descriptor 元数据来检查位置参数数量、把值映射为具名 wire 字段、绑定 scoped Context identity、省略显式为 undefined 的可选值,以及合并取消信号。这些操作都不需要执行运行时 schema。
+
+## Decision
+
+Client Remote 在挂载 contribution 时校验 descriptor 完整性,随后直接转发带类型的参数与绑定的 Context identity,不调用 invocation codec factory。位置参数数量错误与 Client Context binding 缺失仍在本地拒绝。成功的一元结果与流项同样不经 Client 侧类型解析直接传递。
+
+Host Gateway 拥有运行时输入校验。它检查精确具名字段、执行严格参数与 identity codec、验证 JSON 值,并在调用业务代码前完成 lookup。绕过生成 TypeScript API 的 JavaScript 调用方,其请求到达 Host 后会收到 Host 的 `gateway/input-invalid` 结果;无法进入载体的值则可能在序列化时失败。
+
+生成的 Remote contribution 继续携带 codec 元数据,因为 Host 与 Client 产物仍共享 `InvocationDescriptor`,Client 挂载也仍要求每个 Client 供值字段具备严格 codec。完整 Remote 架构见 [Typert 生成的 Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md);本决策只取代其中 Client 侧执行调用 codec 的部分。
+
+## Alternatives considered
+
+- **同时保留 Client 与 Host 输入解析。** 这样能让畸形 JavaScript 调用方更早收到本地错误,并在传输前剔除对象中的未声明属性;但即使 Host 必须独立校验,每次有效调用仍要重复实例化并执行 schema。
+- **从 Remote Client 产物中移除 codec 元数据。** 这样可以进一步缩小生成的 Client 代码,但会改变共享 descriptor 与 generator 协议。保留惰性 factory 能继续检查严格 contribution,同时不产生运行时 schema 构造成本。
+
+## Consequences
+
+正常 Client 调用不再分配 invocation schema,也不再重复执行 Zod parse。Host 校验仍是 lookup 与业务执行前的权威检查,Client 代码则保留参数数量、Context binding、取消与 contribution 生命周期故障。
+
+畸形运行时值会比以前更晚失败。未声明的对象属性可能在 Host codec 剔除它们之前经过可信载体,因此从不可信对象或含秘密对象派生请求的调用方必须构造已声明 DTO,不能把 Client 解析当作脱敏步骤。Client 测试固定原样转发行为,Host 测试固定严格输入与 JSON 输入拒绝。

+ 2 - 0
apps/web/tests/hmr-live.e2e.ts

@@ -17,6 +17,8 @@ const CLIENT_ARTIFACT_PATTERNS = [
   'apps/web/dist/**/*',
   'packages/*/*/lib/client.js',
   'packages/*/*/lib/client.js.map',
+  'packages/*/*/lib/client.*.js',
+  'packages/*/*/lib/client.*.js.map',
 ]
 
 /** Return every artifact that `pnpm run dev:web` can rewrite. */

+ 2 - 2
docs/api-gateway.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/api-gateway.md
-api-gateway.md: 0c64fdf6a528ea6564915a06abb237fd65b91e66
-api-gateway.zh.md: 43bf87865de70a7921502c4105ce1777bc30394e
+api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d
+api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e

+ 2 - 2
docs/api-gateway.md

@@ -85,8 +85,8 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent;
 | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts |
 | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers |
 | Host | `@deepseek-ai/dsh-api-session-controller` | Owns the application Agent/Session identity policy and configures the corresponding Typert lookups |
-| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates request and return values |
-| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.<namespace>` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection |
+| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, validates request values, resolves objects or Contexts, and invokes live Cordis services |
+| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.<namespace>` child Services, mounts generated descriptors as concrete methods, and initiates and cancels calls through the Connection |
 | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code |
 | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge |
 

+ 2 - 2
docs/api-gateway.zh.md

@@ -85,8 +85,8 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导
 | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 |
 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 |
 | Host | `@deepseek-ai/dsh-api-session-controller` | 负责应用的 Agent/Session 身份策略,并配置对应的 Typert lookup |
-| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis 服务,并校验请求值和返回值 |
-| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.<namespace>` 子服务,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 |
+| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,校验请求值,解析对象或 Context,并调用实时 Cordis 服务 |
+| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.<namespace>` 子服务,把生成的描述符挂成具体方法,并通过 Connection 发起和取消调用 |
 | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 |
 | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge |
 

+ 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: daccc3faef1d304f243ec6e07de199e104aee23e
-config-catalog.zh.md: f2e32cd0597b7627127ba1751440f1425cc29670
+config-catalog.md: 90c632c730888c30e38e58676824db0c557b8cc1
+config-catalog.zh.md: 554f64e8659b795285b4fb425ab0f050c210ee34

+ 1 - 3
docs/config-catalog.md

@@ -1810,14 +1810,12 @@ Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/s
 Requires: `loader` · `profileContext`
 
 ```ts config-catalog
-/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 export interface Config {
   /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
   pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
   /** Bound on one registry lookup an inspection runs, in milliseconds. */

+ 23 - 25
docs/config-catalog.zh.md

@@ -31,7 +31,7 @@ export interface AcpConfig {
 }
 ```
 
-依赖:`Stream`(`@agentclientprotocol/sdk`)
+Depends on: `Stream` (`@agentclientprotocol/sdk`)
 
 来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
 
@@ -111,7 +111,7 @@ export interface Config {
 }
 ```
 
-依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
+Depends on: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
 
 来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
 
@@ -181,7 +181,7 @@ export interface Config {
 }
 ```
 
-依赖:[`ToolPresentationMode`](subsystems/tools.zh.md)
+Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md)
 
 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
 
@@ -382,7 +382,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-bash-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local)
 
 来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
 
@@ -585,7 +585,7 @@ export interface Config {
 export type Config = BrowserMcpConfig
 ```
 
-依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
+Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
 
 来源:[`packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts:14`](../packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts)
 
@@ -600,7 +600,7 @@ export type Config = BrowserMcpConfig
 export type Config = BrowserMcpConfig
 ```
 
-依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
+Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`)
 
 来源:[`packages/experimental/browser-use-playwright-mcp/src/index.ts:15`](../packages/experimental/browser-use-playwright-mcp/src/index.ts)
 
@@ -642,7 +642,7 @@ export interface StagehandModelConfig {
 }
 ```
 
-依赖:`ModelConfig` (`@browserbasehq/stagehand`)
+Depends on: `ModelConfig` (`@browserbasehq/stagehand`)
 
 来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts:28`](../packages/experimental/browser-use-stagehand-native/src/index.ts)
 
@@ -666,7 +666,7 @@ export interface Config {
 }
 ```
 
-依赖:[`McpClient`](../packages/mcp/mcp-client/src/index.ts)
+Depends on: [`McpClient`](../packages/mcp/mcp-client/src/index.ts)
 
 来源:[`packages/experimental/computer-use-cua-driver-mcp/src/index.ts:20`](../packages/experimental/computer-use-cua-driver-mcp/src/index.ts)
 
@@ -877,7 +877,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-fs-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local)
 
 来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
 
@@ -935,7 +935,7 @@ export interface HmrConfig extends ChokidarOptions {
 }
 ```
 
-依赖: `ChokidarOptions` (`chokidar`)
+Depends on: `ChokidarOptions` (`chokidar`)
 
 来源: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
 
@@ -1214,7 +1214,7 @@ export interface DeepSeekCatalogModel {
 }
 ```
 
-依赖: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 来源:[`packages/llm/llm-deepseek/src/config.ts:25`](../packages/llm/llm-deepseek/src/config.ts)
 
@@ -1489,7 +1489,7 @@ export type PiAiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFo
 export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['thinkingTokenBudgetField']>
 ```
 
-依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`)
+Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
 
 来源:[`packages/llm/llm-pi-ai/src/config.ts:221`](../packages/llm/llm-pi-ai/src/config.ts)
 
@@ -1568,7 +1568,7 @@ export interface ReplayModelConfig {
 }
 ```
 
-依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 来源:[`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
 
@@ -1756,7 +1756,7 @@ export interface PresetSpec {
 }
 ```
 
-依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
+Depends on: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
 
 来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts)
 
@@ -1812,14 +1812,12 @@ export interface PlanModeConfig {
 依赖: `loader` · `profileContext`
 
 ```ts config-catalog
-/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 export interface Config {
   /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
   pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
   /** Bound on one registry lookup an inspection runs, in milliseconds. */
@@ -1932,7 +1930,7 @@ export interface Config {
 export type Config = LocalConfig
 ```
 
-依赖:[`LocalConfig`](#deepseek-aidsh-pwsh-local)
+Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local)
 
 来源:[`packages/shell/pwsh-sandbox/src/index.ts:40`](../packages/shell/pwsh-sandbox/src/index.ts)
 
@@ -2027,7 +2025,7 @@ export interface Config {
 }
 ```
 
-依赖:[`SandboxMode`](subsystems/sandbox.zh.md)
+Depends on: [`SandboxMode`](subsystems/sandbox.zh.md)
 
 来源:[`packages/sandbox/sandbox-policy/src/index.ts:71`](../packages/sandbox/sandbox-policy/src/index.ts)
 
@@ -2067,7 +2065,7 @@ export interface JsonRpcConfig {
 }
 ```
 
-依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`)
+Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`)
 
 来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts)
 
@@ -2199,7 +2197,7 @@ export type OpenAt = 'startup' | 'first-search' | 'never'
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-依赖:[`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
+Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
 
 来源:[`packages/session-query/session-query-sqlite/src/index.ts:92`](../packages/session-query/session-query-sqlite/src/index.ts)
 
@@ -2266,7 +2264,7 @@ export enum SessionTelemetryMode {
 }
 ```
 
-依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`)
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
 来源:[`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
 
@@ -2301,7 +2299,7 @@ export interface Config {
 export type Config = SessionTitleLlmConfig
 ```
 
-依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
+Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
 
 来源:[`packages/session/session-title-all-prompts-llm/src/index.ts:15`](../packages/session/session-title-all-prompts-llm/src/index.ts)
 
@@ -2316,7 +2314,7 @@ export type Config = SessionTitleLlmConfig
 export type Config = SessionTitleLlmConfig
 ```
 
-依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
+Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
 
 来源:[`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
 
@@ -3270,7 +3268,7 @@ export interface Config {
 }
 ```
 
-依赖:[`AgentOptions`](subsystems/core.zh.md)
+Depends on: [`AgentOptions`](subsystems/core.zh.md)
 
 来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts)
 

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

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

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

@@ -50,9 +50,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:185`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:191`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:191`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:203`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:255`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

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

@@ -52,9 +52,9 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:185`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:191`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:191`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:203`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:255`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

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

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

+ 1 - 3
docs/module-graph.md

@@ -1086,11 +1086,9 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
-  pkg_plugin_manager --> pkg_agent
   pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
-  pkg_plugin_manager --> pkg_llm
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
@@ -1548,7 +1546,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

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

@@ -1088,11 +1088,9 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
-  pkg_plugin_manager --> pkg_agent
   pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
-  pkg_plugin_manager --> pkg_llm
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
@@ -1550,7 +1548,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/boot.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/boot.md
-boot.md: 079fd9d705d43a85fb84cb3bd0983cd49169a4d6
-boot.zh.md: bc2f847be044670c726989c107664cf88206e9fa
+boot.md: b849df05977400924ab3420e1518c1468c9a27a2
+boot.zh.md: 14a2b427862e0c468a2acefcf1f11af34775fdcf

+ 5 - 8
docs/subsystems/boot.md

@@ -10,15 +10,11 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
 
 `PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`.
 
-`BundleInfo` carries the package name, optional installed version, title and description, whether the profile's own dependencies hold it, selected enablement, removal availability, optional resolution error, the rows its patch declares (`BundleRowInfo`: row id, module, and the live entry id while the bundle contributes it), and the ids of existing rows its patch overrides.
+`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
 
-`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `requestId` (`PluginInstallRequestId`, a caller-generated UUID) identifies the installation for its log and state events and for `cancelInstall`, which answers a `PluginInstallCancellation`: `cancelled`, `too-late`, or `not-running`.
+`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation.
 
-`PluginSpecInspection` is what `inspect` answers before an install: an accepted spec's form, name, version, description, title, and bundle declaration, or a refusal's problem and reason.
-
-`PluginInstallLogChunk` is one chunk of a pnpm run's output under its request id and job id, with the command line, the directory, the stream, and the exit code on the run's last chunk; `PluginInstallProgress` is an installation's request id and Host phase; `PluginChange` names the operation behind a `plugin-manager/changed` event, or `reload` for a patch generation applied outside the manager.
-
-`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden`, `failed` or `cancelled`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag, complete diagnostic log path, and the failure kind of a failed run; optional `bundle` names the package an installation added.
+`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -102,7 +98,8 @@ Manage profile files and apply their declared reload lifecycle.
  * that fails, is cancelled, or adds a package without a bundle patch restores
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
  * @param spec One package spec, including local paths relative to the invocation directory.
- * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+ * the pending build scripts to allow for this profile before pnpm runs.
  * @returns Package-manager diagnostics and observed activation outcome.
  */
 @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>

+ 5 - 8
docs/subsystems/boot.zh.md

@@ -10,15 +10,11 @@
 
 `PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。
 
-`BundleInfo` 包含包名、可选的安装版本、标题与描述、profile 自身依赖是否持有该包、组合层选择状态、删除可用性、可选的解析错误、其 patch 声明的行(`BundleRowInfo`:行 id、模块,以及组合包生效期间的存活条目 id),以及其 patch 覆盖的已有行 id。
+`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
 
-`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。`requestId`(`PluginInstallRequestId`,调用方生成的 UUID)标识这次安装,用于其日志与状态事件以及 `cancelInstall`,后者答复 `PluginInstallCancellation`:`cancelled`、`too-late` 或 `not-running`。
+`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。
 
-`PluginSpecInspection` 是 `inspect` 在安装前的答复:被接受的 spec 的形式、名称、版本、描述、标题与组合包声明,或拒绝的问题与原因。
-
-`PluginInstallLogChunk` 是 pnpm 运行输出的一块,归于其 request id 与 job id 之下,带命令行、目录、流,以及运行最后一块上的退出码;`PluginInstallProgress` 是一次安装的 request id 与 Host 阶段;`PluginChange` 给出 `plugin-manager/changed` 事件背后的操作,管理器之外应用的一代 patch 记为 `reload`。
-
-`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden`、`failed` 或 `cancelled`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记、完整诊断日志路径,以及失败运行的失败类别;可选的 `bundle` 给出安装新增的包。
+`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -102,7 +98,8 @@ Manage profile files and apply their declared reload lifecycle.
  * that fails, is cancelled, or adds a package without a bundle patch restores
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
  * @param spec One package spec, including local paths relative to the invocation directory.
- * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+ * the pending build scripts to allow for this profile before pnpm runs.
  * @returns Package-manager diagnostics and observed activation outcome.
  */
 @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>

+ 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: aff74a1da1ce524689d1a746a7267eb1544e4fef
-client-modules.zh.md: 8100708edd88d35f123d658de42525f04a90ebd6
+client-modules.md: 89f3e675628bca71f001170b5efabb241134dfb0
+client-modules.zh.md: 1a41b4bd061ebf83f80c6f7c1633932da13b5942

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

@@ -151,8 +151,8 @@ async fetchBundle(request: Request): Promise<Response>
 artifactBaseline(id: string): ClientArtifactBaseline | undefined
 
 /**
- * Re-hash one bundle (the HMR watch's registration hook — the only entry
- * point through which bundle content changes reach the graph).
+ * Publish one completed bundle generation (the HMR watch's registration
+ * hook — the only entry point through which build changes reach the graph).
  * @param id - entry id (package name).
  * @returns the new rev, or undefined for an unknown id.
  */

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

@@ -151,8 +151,8 @@ async fetchBundle(request: Request): Promise<Response>
 artifactBaseline(id: string): ClientArtifactBaseline | undefined
 
 /**
- * Re-hash one bundle (the HMR watch's registration hook — the only entry
- * point through which bundle content changes reach the graph).
+ * Publish one completed bundle generation (the HMR watch's registration
+ * hook — the only entry point through which build changes reach the graph).
  * @param id - entry id (package name).
  * @returns the new rev, or undefined for an unknown id.
  */

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: 999ca77d47c12d9b1d5cc749fc854069e597f767
-tool-catalog.zh.md: 32db6d25e5377b882de62c034343c1feb66abd16
+tool-catalog.md: 0166c7dd9829f0774c277508299726aac8426969
+tool-catalog.zh.md: 7722301c04febbd7b8d1c5509bfdb9e3881d0b76

+ 7 - 0
docs/tool-catalog.md

@@ -77,6 +77,13 @@ List plugins or bundles in the current profile, enable or disable them, install
       "type": "boolean",
       "description": "Required for set operations; defaults to true for installation."
     },
+    "approvedBuilds": {
+      "type": "array",
+      "description": "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.",
+      "items": {
+        "type": "string"
+      }
+    },
     "offset": {
       "type": "number",
       "description": "Zero-based list offset; defaults to 0."

+ 7 - 0
docs/tool-catalog.zh.md

@@ -81,6 +81,13 @@
       "type": "boolean",
       "description": "Required for set operations; defaults to true for installation."
     },
+    "approvedBuilds": {
+      "type": "array",
+      "description": "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.",
+      "items": {
+        "type": "string"
+      }
+    },
     "offset": {
       "type": "number",
       "description": "Zero-based list offset; defaults to 0."

+ 2 - 2
packages/api/gateway/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/gateway/README.md
-README.md: ee571582fb4599aaca789dc6471a1febeb5d20ce
-README.zh.md: 8a4054551a5db23bdce6fcb08c5323d96f2f680d
+README.md: 68d51adb8f801d35ca9bc44cc449aed66c3b5c2d
+README.zh.md: 128db2e88267a7ad10fc26a6c1c22274ad51aed8

+ 5 - 5
packages/api/gateway/README.md

@@ -24,7 +24,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
 
-`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
+`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, and invokes the public business method. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
 
 Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
 
@@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
 
 A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
 
-A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
+A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
 
 Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.
 
@@ -41,9 +41,9 @@ Host composition can register one application event source through `registerRemo
 
 The browser carrier accepts the shell-owned stream origin described by [Connection](../../client/connection/README.md#use-this-package); logical stream framing and lifecycle remain unchanged.
 
-`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
+`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable.
 
-Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
+Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
 
 Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically.
 
@@ -72,7 +72,7 @@ No direct effect; invoked business Services own any model-visible result.
 
 - The Connection adapter answers `gateway/internal` with empty details for dispatch failures and unclassified exceptions; a `RemoteError` thrown by an owner or by Gateway itself crosses the wire with its own code, message, and details. Its `cause` chain and the `TypertGatewayError` subclass identity survive only for same-process callers.
 - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
-- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
+- Every Client-supplied field requires a strict generated codec when its contribution mounts. SRC markers have no Client type projection and are not normal Client contribution inputs.
 - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay.
 - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
 - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime.

+ 5 - 5
packages/api/gateway/README.zh.md

@@ -24,7 +24,7 @@ kind: "package-reference"
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
 
-每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
+每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,并调用公开的业务方法。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
 
 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
 
@@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
 
 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
 
-流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
+流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
 
 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。
 
@@ -41,9 +41,9 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 浏览器载体接受 [Connection](../../client/connection/README.zh.md#use-this-package) 定义的 shell 所拥有的流 origin;逻辑流帧与生命周期保持一致。
 
-`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
+`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。
 
-每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
+每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
 
 每次一元调用都解析为 `RemoteResult<T>`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。
 
@@ -72,7 +72,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 - Connection 适配器对分发故障与未归类异常答以 `gateway/internal`,且不附带详细信息;拥有方或 Gateway 自己抛出的 `RemoteError` 带着自有码、message 与 details 过线。其 `cause` 链与 `TypertGatewayError` 子类身份只对同进程调用方留存。
 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
-- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
+- contribution 挂载时,每个由 Client 供值的字段都必须具备生成的严格 codec。SRC 标记没有 Client 类型投影,不是常规的 Client contribution 输入。
 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events` 流;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。
 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。

+ 4 - 18
packages/api/gateway/src/client/index.ts

@@ -53,7 +53,6 @@ interface MountToken {
 interface ScopedProjection {
   readonly context: string
   readonly wire: string
-  readonly codec: TypertCodec
   readonly parameterIndex?: number
 }
 
@@ -284,7 +283,7 @@ class ClientRemoteService extends Service implements ClientRemote {
       }
     }
     for (const descriptor of contribution.descriptors) {
-      requireStrictDescriptor(descriptor)
+      requireStrictInputs(descriptor)
       if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
       if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
     }
@@ -505,12 +504,12 @@ class ClientRemoteService extends Service implements ClientRemote {
       if (identity === undefined) {
         throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
       }
-      args[projection.wire] = parseInput(projection.codec, identity, endpoint, projection.wire)
+      args[projection.wire] = identity
     }
     let valueIndex = 0
     descriptor.parameters.forEach((parameter, parameterIndex) => {
       if (parameterIndex === projection?.parameterIndex) return
-      const value = parseInput(parameter.codec, values[valueIndex], endpoint, parameter.wire)
+      const value = values[valueIndex]
       if (value !== undefined) args[parameter.wire] = value
       valueIndex += 1
     })
@@ -673,7 +672,6 @@ function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection |
     return {
       context: descriptor.invocation.context,
       wire: descriptor.invocation.wire,
-      codec: descriptor.invocation.codec,
     }
   }
   if (descriptor.scope === undefined) return undefined
@@ -691,12 +689,11 @@ function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection |
   return {
     context: descriptor.scope.context,
     wire: descriptor.scope.wire,
-    codec: selected.parameter.codec,
     parameterIndex: selected.index,
   }
 }
 
-function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
+function requireStrictInputs(descriptor: InvocationDescriptor): void {
   const endpoint = endpointOf(descriptor)
   for (const parameter of descriptor.parameters) {
     requireStrictCodec(parameter.codec, endpoint, parameter.wire)
@@ -712,17 +709,6 @@ function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string)
   }
 }
 
-function parseInput(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown {
-  if (codec.mode !== 'strict') {
-    throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
-  }
-  try {
-    return codec.create().parse(value)
-  } catch (cause) {
-    throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
-  }
-}
-
 /** The namespace retired before or during the call, so no request outcome exists. */
 function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok: false }> {
   return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)

+ 10 - 7
packages/api/gateway/tests/gateway.client.spec.ts

@@ -719,7 +719,7 @@ describe('Client Remote transport readiness', () => {
 })
 
 describe('Client Typert API', () => {
-  it('mounts concrete direct methods, validates inputs, and withdraws retained handles', async () => {
+  it('mounts concrete direct methods, forwards inputs, and withdraws retained handles', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
     const ctx = await bench(call)
@@ -753,7 +753,14 @@ describe('Client Typert API', () => {
     callerAbort.abort(cancellation)
     expect(combinedSignal?.aborted).toBe(true)
     expect(combinedSignal?.reason).toBe(cancellation)
-    await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
+    await expect(ctx.remote.probe.create('', { objective: 'ship' }))
+      .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
+    expect(call).toHaveBeenLastCalledWith(
+      '/api',
+      'probe/create',
+      { args: { agentId: '', request: { objective: 'ship' } } },
+      expect.any(AbortSignal),
+    )
 
     call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
     await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
@@ -1161,7 +1168,7 @@ describe('Client Typert API', () => {
     })).rejects.toThrow('scope must select its only lookup parameter')
   })
 
-  it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => {
+  it('validates invocation arity and required adapters, and requires a live Connection', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
     const ctx = await bench(call)
@@ -1183,10 +1190,6 @@ describe('Client Typert API', () => {
     await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
       .rejects.toThrow('no Client Context adapter')
 
-    ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
-    await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
-    ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
-
     ctx.set('connection', undefined)
     await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
     await dispose()

+ 4 - 9
packages/api/remotes/tests/built-lib.e2e.ts

@@ -176,12 +176,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
         identity: candidate => candidate.builtAgentId,
       })
 
-      let invalidRejected = false
-      try {
-        await client.remote.goals.create(rootAgent.id, { objective: 1 })
-      } catch {
-        invalidRejected = true
-      }
+      const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 })
       // Every generated method resolves to the RemoteResult envelope; the
       // business values below are what the assertions pin.
       const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
@@ -193,7 +188,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
       const agentContext = client.extend({ builtAgentId: scopedAgent.id })
       const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
       const result = {
-        invalidRejected,
+        invalidResult,
         rootResult: rootResult.value,
         rootEdit: rootEdit.value,
         scopedResult: scopedResult.value,
@@ -215,7 +210,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
     const result = await runPlainNode(script)
     expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
     const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
-      invalidRejected: boolean
+      invalidResult: { ok: boolean; error?: { code: string } }
       rootResult: { ref: { id: string; revision: number } }
       rootEdit: { objective: string; revision: number }
       scopedResult: { ref: { id: string; revision: number } }
@@ -225,7 +220,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
       scopedEvents: number
     }
     expect(output).toMatchObject({
-      invalidRejected: true,
+      invalidResult: { ok: false, error: { code: 'gateway/input-invalid' } },
       rootResult: { ref: { revision: 1 } },
       rootEdit: { objective: 'edited root goal', revision: 2 },
       scopedResult: { ref: { revision: 1 } },

+ 2 - 2
packages/boot/plugin-manager/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/boot/plugin-manager/README.md
-README.md: b9c9942c947557ccc4142504affb8a8f68508013
-README.zh.md: 7d8f78af5c23a6dd2b2e0e3316296dc1913d29c4
+README.md: a83314e3994bfe39bfbcfe84e3b698ff76c3fce9
+README.zh.md: be2a205115c4d8e4fc52a3985f904012efce5775

+ 4 - 16
packages/boot/plugin-manager/README.md

@@ -41,6 +41,8 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
 
 `installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's title and one-liner (`dsh.title` and `dsh.description`, else the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the installation's manifest names under `dsh.optionalBundles` is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation, and every patch generation applied outside the manager, emits `plugin-manager/changed`.
 
+When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`, where pnpm records them. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice.
+
 ### Configuration
 
 | Field | Default | Meaning |
@@ -49,7 +51,6 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
 | `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
 | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
 | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. |
-| `notificationDelayMs` | `250` | Delay in milliseconds for combining operation notices. |
 
 -----
 
@@ -82,7 +83,7 @@ Results contain the last attempted stage, target, saved-state change, applicatio
 
 #### What the model sees
 
-The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics.
+The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. Management operations do not inject messages into Agents.
 
 #### Token effect
 
@@ -92,24 +93,11 @@ The tool declaration is present when its consumer is mounted; each invocation ad
 
 Tool results append to the transcript. Enabling or disabling other tools can change subsequent tool declarations and their cache reuse.
 
-### Configuration change notices
-
-#### What the model sees
-
-Consecutive operation results are combined within `notificationDelayMs` and injected into each affected live Agent. Notices include the application outcome, disclose omitted results when the configured output bound is reached, and do not wake an idle Agent.
-
-#### Token effect
-
-Notices add conditional user-message context to each affected Agent.
-
-#### KV Cache effect
-
-Notices append context; they do not rewrite earlier messages.
-
 ## Known Limitations and Deferred Work
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- Web approves the entire displayed pending group; it has no per-package selection.
 - Package replacements require restarting the process to load a fresh JavaScript module generation.
 - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
 - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.

+ 4 - 16
packages/boot/plugin-manager/README.zh.md

@@ -41,6 +41,8 @@ kind: "package-reference"
 
 `installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的标题与一句话简介(`dsh.title` 与 `dsh.description`,没有则用包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。安装的 manifest 在 `dsh.optionalBundles` 下点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作,以及在管理器之外应用的每一代 patch,都会发出 `plugin-manager/changed`。
 
+pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 pnpm 记录这些名字的 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
+
 ### 配置
 
 | 字段 | 默认值 | 含义 |
@@ -49,7 +51,6 @@ kind: "package-reference"
 | `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
 | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
 | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
-| `notificationDelayMs` | `250` | 合并操作通知的延迟毫秒数。 |
 
 -----
 
@@ -82,7 +83,7 @@ kind: "package-reference"
 
 #### 模型看到什么
 
-[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。
+[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。管理操作不会向 Agent 注入消息。
 
 #### Token 影响
 
@@ -92,24 +93,11 @@ kind: "package-reference"
 
 工具结果追加到对话中。启停其他工具可能改变后续工具声明及其缓存复用。
 
-### 配置变更通知
-
-#### 模型看到什么
-
-连续操作结果在 `notificationDelayMs` 内合并后注入每个受影响的存活 Agent。通知包含应用结果,达到配置的输出上限时标明省略的结果数,不会唤醒空闲 Agent。
-
-#### Token 影响
-
-通知按需向每个受影响 Agent 追加用户消息上下文。
-
-#### KV Cache 影响
-
-通知追加上下文,不改写先前消息。
-
 ## 已知限制与延期工作
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- Web 一次批准显示出来的整组待决定包,没有逐包选择。
 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。

+ 0 - 8
packages/boot/plugin-manager/package.json

@@ -63,9 +63,7 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
@@ -73,9 +71,6 @@
     "@deepseek-ai/dsh-brand": "workspace:^"
   },
   "peerDependenciesMeta": {
-    "@deepseek-ai/dsh-agent": {
-      "optional": true
-    },
     "@deepseek-ai/dsh-hmr": {
       "optional": true
     }
@@ -83,14 +78,11 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^"

+ 50 - 0
packages/boot/plugin-manager/src/build-approval.ts

@@ -0,0 +1,50 @@
+/** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { isAlias, isMap, isNode, isScalar, parseDocument, visit } from 'yaml'
+import { ManagementFailure } from './failure.ts'
+import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
+
+async function readPolicy(dir: string) {
+  let text: string
+  try { text = await readFile(join(dir, 'pnpm-workspace.yaml'), 'utf8') }
+  catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    text = '{}\n'
+  }
+  const document = parseDocument(text)
+  if (document.errors[0] !== undefined) throw document.errors[0]
+  if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml must be a YAML mapping')
+  const builds = document.get('allowBuilds')
+  if (builds !== undefined && !isMap(builds)) throw new Error('allowBuilds must be a YAML mapping')
+  visit(builds ?? null, (_key, node) => {
+    if (isAlias(node) || (isNode(node) && 'anchor' in node && node.anchor)) {
+      throw new Error('allowBuilds must not contain YAML anchors or aliases')
+    }
+  })
+  const pending = isMap(builds) ? builds.items.flatMap(({ key, value }) =>
+    isScalar(key) && typeof key.value === 'string' && !/[*?]/.test(key.value)
+      && isScalar(value) && value.value === 'set this to true or false' ? [key.value] : []) : []
+  return { document, pending }
+}
+
+/** Read package names left undecided by pnpm 11, including after installation cleanup.
+ * @param dir Current profile directory.
+ * @returns Exact package names awaiting a build decision; wildcard rules are excluded.
+ */
+export async function readPendingBuilds(dir: string): Promise<string[]> {
+  return (await readPolicy(dir)).pending
+}
+
+/** Persist approval without running scripts; the caller holds the profile manifest lock.
+ * @param dir Current profile directory.
+ * @param names Explicit package names from the pending build list.
+ * @throws If a name is no longer pending or allowBuilds contains YAML anchors or aliases; no approvals are written.
+ */
+export async function approveBuilds(dir: string, names: readonly string[]): Promise<void> {
+  const { document, pending } = await readPolicy(dir)
+  if (names.some(name => !pending.includes(name))) throw new ManagementFailure('stale-approval')
+  if (names.length === 0) return
+  for (const name of names) document.setIn(['allowBuilds', name], true)
+  await writeFileAtomic(join(dir, 'pnpm-workspace.yaml'), String(document), { mode: 0o600 })
+}

+ 10 - 0
packages/boot/plugin-manager/src/failure.ts

@@ -0,0 +1,10 @@
+/** Localizable rejections shared by profile management operations. */
+import type { ManagementError } from './types.ts'
+
+/** Expected management rejection; presentation belongs to the caller's locale. */
+export class ManagementFailure extends Error {
+  /** Code rendered by the caller's locale dictionary. */
+  readonly code: ManagementError['code']
+  /** @param code Localizable management rejection. */
+  constructor(code: ManagementError['code']) { super(code); this.code = code }
+}

+ 19 - 70
packages/boot/plugin-manager/src/index.ts

@@ -13,12 +13,12 @@ import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin
 import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-hmr'
 import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts'
 import { classifyInstallFailure } from './install-failure.ts'
 import { InvalidInstallSpecError, parseInstallSpec } from './install-spec.ts'
 import { writePluginEnabled } from './patch.ts'
+import { ManagementFailure } from './failure.ts'
+import { approveBuilds, readPendingBuilds } from './build-approval.ts'
 import type {
   BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo,
   PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection,
@@ -27,14 +27,12 @@ export type * from './types.ts'
 export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
 export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
 
-/** The pnpm executable and the limits for package diagnostics, registry lookups, and change notifications. */
+/** The pnpm executable and the limits for package diagnostics and registry lookups. */
 export interface Config {
   /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
   pnpmCommand?: string
   /** Maximum retained pnpm diagnostic bytes per operation. */
   outputBytes?: number
-  /** Delay for combining consecutive management notices in one durable injection. */
-  notificationDelayMs?: number
   /** Maximum time to wait for another process's profile package operation. */
   lockWaitMs?: number
   /** Bound on one registry lookup an inspection runs, in milliseconds. */
@@ -72,12 +70,6 @@ function managementError(error: unknown): ManagementError {
   return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) }
 }
 
-/** Expected management rejection; presentation belongs to the caller's locale. */
-class ManagementFailure extends Error {
-  readonly code: ManagementError['code']
-  constructor(code: ManagementError['code']) { super(code); this.code = code }
-}
-
 /** The caller stopped an installation; its files are restored before this is thrown. */
 class InstallCancelledError extends Error {
   constructor() {
@@ -142,7 +134,6 @@ export class PluginManager extends TypertRemoteService {
   static Config: z<Config> = z.object({
     pnpmCommand: z.string().default('pnpm'),
     outputBytes: z.number().step(1).min(1).default(16384),
-    notificationDelayMs: z.number().step(1).min(0).default(250),
     lockWaitMs: z.number().step(1).min(0).default(120000),
     inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
   })
@@ -150,16 +141,10 @@ export class PluginManager extends TypertRemoteService {
   private readonly packageOperations = new Set<Promise<unknown>>()
   private readonly profile: ProfileContext
   private readonly outputBytes: number
-  private readonly notificationDelayMs: number
   private readonly lockWaitMs: number
   private readonly inspectTimeoutMs: number
   private readonly pnpmCommand: string
   private readonly ownerContext: Context
-  private pendingNotice = ''
-  private omittedNotices = 0
-  private noticeTimer: ReturnType<typeof setTimeout> | undefined
-  private noticeDelivered: PromiseWithResolvers<void> | undefined
-  private readonly noticeAgents = new Set<Agent>()
   private readonly abort = new AbortController()
   /** Installations by request id, from their call until it settles. */
   private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
@@ -172,15 +157,12 @@ export class PluginManager extends TypertRemoteService {
     this.ownerContext = ctx
     this.profile = ctx.profileContext
     this.outputBytes = (config as Required<Config>).outputBytes
-    this.notificationDelayMs = (config as Required<Config>).notificationDelayMs
     this.lockWaitMs = (config as Required<Config>).lockWaitMs
     this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
     this.pnpmCommand = (config as Required<Config>).pnpmCommand
     ctx.effect(() => async () => {
       this.abort.abort()
       await Promise.allSettled([...this.packageOperations])
-      clearTimeout(this.noticeTimer)
-      this.flushNotice()
     }, 'plugin-manager: package cancellation')
     // A patch generation applied outside a manager operation — HMR's file
     // watcher after a CLI or hand edit — changes what the lists show.
@@ -359,7 +341,8 @@ export class PluginManager extends TypertRemoteService {
    * that fails, is cancelled, or adds a package without a bundle patch restores
    * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
    * @param spec One package spec, including local paths relative to the invocation directory.
-   * @param options Whether to activate the installed bundle (defaults to true) and the request id a cancellation names.
+   * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
+   * the pending build scripts to allow for this profile before pnpm runs.
    * @returns Package-manager diagnostics and observed activation outcome.
    */
   @Remote
@@ -374,6 +357,10 @@ export class PluginManager extends TypertRemoteService {
     const result = this.change(async (result) => {
       if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec')
       if (stopped()) throw new InstallCancelledError()
+      if (options?.approvedBuilds !== undefined) {
+        await approveBuilds(this.profile.dir, options.approvedBuilds)
+        result.approvedBuilds = options.approvedBuilds
+      }
       const files = await this.readRestoredFiles()
       const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
       announce('installing')
@@ -381,7 +368,14 @@ export class PluginManager extends TypertRemoteService {
       try {
         result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId)
         if (stopped()) throw new InstallCancelledError()
-        if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
+        if (result.packageResult.exitCode !== 0) {
+          // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
+          try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
+          catch (error) {
+            this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
+          }
+          throw new Error(result.packageResult.output)
+        }
         const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
         const installed = Object.keys(after).filter(name => before[name] !== after[name])
         // Registry retries can retain the saved range after a partial installation.
@@ -576,8 +570,7 @@ export class PluginManager extends TypertRemoteService {
     request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
     reason: PluginChange['reason'],
   ): Promise<ChangeResult> {
-    let notice: Promise<void> | undefined
-    const locked = () => withFileLock(join(this.profile.dir, 'package.json'), async () => {
+    return withFileLock(join(this.profile.dir, 'package.json'), async () => {
       this.abort.signal.throwIfAborted()
       this.operating = true
       const before = this.diskState()
@@ -596,17 +589,13 @@ export class PluginManager extends TypertRemoteService {
         this.operating = false
       }
       result.changed = before !== this.diskState()
-      notice = this.notify(result)
       this.ownerContext.emit('plugin-manager/changed', { reason })
       return result
     }, { waitMs: this.lockWaitMs })
-    const result = await locked()
-    await notice
-    return result
   }
 
   private diskState(): string {
-    return ['package.json', 'cordis.patch.yml'].map((file) => {
+    return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => {
       try { return readFileSync(join(this.profile.dir, file), 'utf8') }
       catch (error) {
         if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
@@ -614,46 +603,6 @@ export class PluginManager extends TypertRemoteService {
       }
     }).join('\0')
   }
-
-  private notify(result: ChangeResult): Promise<void> {
-    const agents = this.ownerContext.get('agents')?.list() ?? []
-    if (agents.length === 0) return Promise.resolve()
-    for (const agent of agents) this.noticeAgents.add(agent)
-    this.noticeDelivered ??= Promise.withResolvers<void>()
-    const delivered = this.noticeDelivered.promise
-    const notice = JSON.stringify({ profile: this.profile.name, ...result })
-    if (Buffer.byteLength(this.pendingNotice + notice) > this.outputBytes) {
-      this.omittedNotices += 1
-    } else {
-      this.pendingNotice += `${notice}\n`
-    }
-    if (this.noticeTimer !== undefined) return delivered
-    this.noticeTimer = setTimeout(() => { this.flushNotice() }, this.notificationDelayMs)
-    return delivered
-  }
-
-  private flushNotice(): void {
-    this.noticeTimer = undefined
-    const text = this.pendingNotice + (this.omittedNotices === 0 ? '' : JSON.stringify({ omitted: this.omittedNotices, refresh: 'plugin_manager' }) + '\n')
-    this.pendingNotice = ''
-    this.omittedNotices = 0
-    const delivered = this.noticeDelivered
-    this.noticeDelivered = undefined
-    if (delivered === undefined) return
-    const agents = [...this.noticeAgents]
-    this.noticeAgents.clear()
-    for (const agent of agents) {
-      try {
-        agent.inject(createUserMessage({
-          content: [{ type: 'text', text }],
-          source: { kind: 'plugin', plugin: 'plugin-manager' },
-        }))
-      } catch (error) {
-        this.ownerContext.logger.warn('Plugin management notification could not reach an Agent', error)
-      }
-    }
-    delivered.resolve()
-  }
 }
 
 export default PluginManager

+ 5 - 1
packages/boot/plugin-manager/src/tools.ts

@@ -19,6 +19,7 @@ export function apply(ctx: Context): void {
       action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
       target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
       enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' },
+      approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
       offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
       limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
     },
@@ -50,7 +51,10 @@ export function apply(ctx: Context): void {
         }
         case 'install_bundle':
           if (args.target === undefined) throw new Error('target package spec is required')
-          return JSON.stringify(await manager.installBundle(args.target, args.enabled === undefined ? {} : { enabled: args.enabled }))
+          return JSON.stringify(await manager.installBundle(args.target, {
+            ...args.enabled === undefined ? {} : { enabled: args.enabled },
+            ...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds },
+          }))
         case 'remove_bundle':
           if (args.target === undefined) throw new Error('target bundle name is required')
           return JSON.stringify(await manager.removeBundle(args.target))

+ 7 - 1
packages/boot/plugin-manager/src/types.ts

@@ -9,7 +9,7 @@ export type ReadOnlyReason = 'management-required' | 'unaddressable'
 
 /** Localizable management failure and optional external diagnostic. */
 export interface ManagementError {
-  code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'operation-error'
+  code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'operation-error'
   diagnostic?: string
 }
 
@@ -92,6 +92,10 @@ export interface ChangeResult {
   packageResult?: PackageResult
   /** The bundle an installation added, once pnpm and the bundle check accepted it. */
   bundle?: string
+  /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */
+  pendingBuilds?: string[]
+  /** Package script permissions saved before this installation attempt. */
+  approvedBuilds?: string[]
 }
 
 /** Identifies one installation from its start to its settlement, including its log chunks and cancellation. */
@@ -101,6 +105,8 @@ export type PluginInstallRequestId = Branded<'PluginInstallRequestId'>
 export interface InstallBundleOptions {
   enabled?: boolean
   requestId?: PluginInstallRequestId
+  /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */
+  approvedBuilds?: string[]
 }
 
 /** The form one install spec takes, in pnpm's vocabulary. */

+ 69 - 0
packages/boot/plugin-manager/tests/build-approval.spec.ts

@@ -0,0 +1,69 @@
+/** Pending script permissions survive cleanup and preserve unrelated workspace settings. */
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { expect, it, onTestFinished } from 'vitest'
+import { parse } from 'yaml'
+import { approveBuilds, readPendingBuilds } from '../src/build-approval.ts'
+
+function fixture(text?: string) {
+  const dir = mkdtempSync(join(tmpdir(), 'build-approval-'))
+  onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
+  const filename = join(dir, 'pnpm-workspace.yaml')
+  if (text !== undefined) writeFileSync(filename, text)
+  return { dir, filename }
+}
+
+it('approves only named pending packages and preserves comments, decisions and settings', async () => {
+  const { dir, filename } = fixture('# profile settings\nother: &unrelated value\ncopy: *unrelated\nnodeLinker: hoisted\nallowBuilds:\n  native: set this to true or false\n  "@scope/other": set this to true or false\n  trusted: true\n  denied: false\n  "@scope/*": set this to true or false\n')
+  expect(await readPendingBuilds(dir)).toEqual(['native', '@scope/other'])
+  await approveBuilds(dir, ['native'])
+  const text = readFileSync(filename, 'utf8')
+  expect(text).toContain('# profile settings')
+  expect(parse(text)).toMatchObject({ nodeLinker: 'hoisted', allowBuilds: { native: true, trusted: true, denied: false } })
+  expect(await readPendingBuilds(dir)).toEqual(['@scope/other'])
+})
+
+it.each(['missing', 'denied', '*', '--all'])('rejects an unlisted approval atomically: %s', async (name) => {
+  const original = 'allowBuilds:\n  native: set this to true or false\n  denied: false\n'
+  const { dir, filename } = fixture(original)
+  await expect(approveBuilds(dir, ['native', name])).rejects.toThrow('stale-approval')
+  expect(readFileSync(filename, 'utf8')).toBe(original)
+})
+
+it('preserves pnpm file dependency selectors verbatim', async () => {
+  const name = '@scope/addon@file:../local addon'
+  const { dir, filename } = fixture(`allowBuilds:\n  '${name}': set this to true or false\n`)
+  expect(await readPendingBuilds(dir)).toEqual([name])
+  await approveBuilds(dir, [name])
+  expect(parse(readFileSync(filename, 'utf8'))).toEqual({ allowBuilds: { [name]: true } })
+})
+
+it.each([undefined, '{}\n', 'nodeLinker: hoisted\n', 'allowBuilds: {}\n'])('has no pending approval without pnpm placeholders: %s', async (text) => {
+  const { dir } = fixture(text)
+  expect(await readPendingBuilds(dir)).toEqual([])
+  await approveBuilds(dir, [])
+})
+
+it.each(['[', '[]\n', 'allowBuilds: false\n'])('rejects malformed workspace settings without rewriting them: %s', async (text) => {
+  const { dir, filename } = fixture(text)
+  await expect(readPendingBuilds(dir)).rejects.toThrow()
+  await expect(approveBuilds(dir, ['native'])).rejects.toThrow()
+  expect(readFileSync(filename, 'utf8')).toBe(text)
+})
+
+it('reports unreadable workspace settings', async () => {
+  const { dir, filename } = fixture()
+  mkdirSync(filename)
+  await expect(readPendingBuilds(dir)).rejects.toThrow()
+})
+
+it.each([
+  'allowBuilds:\n  native: &pending set this to true or false\n  other: *pending\n',
+  'allowBuilds: &builds\n  native: set this to true or false\nshared: *builds\n',
+  'shared: &pending set this to true or false\nallowBuilds:\n  native: *pending\n',
+])('rejects shared YAML approval nodes without changing permissions: %s', async (original) => {
+  const { dir, filename } = fixture(original)
+  await expect(approveBuilds(dir, ['native'])).rejects.toThrow()
+  expect(readFileSync(filename, 'utf8')).toBe(original)
+})

+ 78 - 36
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -1,12 +1,10 @@
 /** Persistent manager behavior through a real profile Include and Loader. */
 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { realpath } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { pathToFileURL } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
-import type AgentRegistry from '@deepseek-ai/dsh-agent'
-import { Session, SessionId } from '@deepseek-ai/dsh-session'
-import type { UserMessage } from '@deepseek-ai/dsh-llm'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import {
   boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches,
@@ -18,9 +16,11 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { Group } from '@deepseek-ai/cordis-plugin-loader'
 import * as operations from '../src/operations.ts'
+import { parse, parseDocument } from 'yaml'
 
 async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
-  const home = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
+  // pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
+  const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
   const dir = join(home, 'profiles', 'test')
   const anchor = join(home, 'package.json')
   writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
@@ -176,6 +176,80 @@ it('installs only valid bundle declarations and honors installation without acti
   expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
 })
 
+it('reports blocked scripts after a failed installation and retries only after explicit profile build approval', async () => {
+  const { manager, dir, bundle } = await fixture()
+  const policy = join(dir, 'pnpm-workspace.yaml')
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    const manifest = readProfileManifest('test', dir)
+    const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
+    manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
+    if (run.mock.calls.length === 1) {
+      writeFileSync(policy, 'allowBuilds:\n  native: set this to true or false\n  denied: false\n')
+      completion.exitCode = 1
+      completion.output = 'ERR_PNPM_IGNORED_BUILDS'
+    } else {
+      expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
+      bundle('addon', [])
+    }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return completion
+  })
+  onTestFinished(() => { run.mockRestore() })
+  // The failed run's manifest change is put back; the policy pnpm wrote stays, so its undecided names can be offered.
+  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', pendingBuilds: ['native'] })
+  expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
+  expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } })
+  expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false, error: { code: 'stale-approval' } })
+  expect(run).toHaveBeenCalledTimes(1)
+  expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({
+    application: 'applied', changed: true, approvedBuilds: ['native'],
+  })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
+})
+
+it('retains approved policy and reports it as changed when the registry fails before adding a dependency', async () => {
+  const { manager, dir } = await fixture()
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'allowBuilds:\n  native: set this to true or false\n')
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'registry unavailable', truncated: false, logPath: '/log' })
+  onTestFinished(() => { run.mockRestore() })
+  expect(await manager.installBundle('addon', { approvedBuilds: ['native'] })).toMatchObject({
+    changed: true, application: 'failed', pendingBuilds: [], approvedBuilds: ['native'], error: { diagnostic: 'registry unavailable' },
+  })
+  expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } })
+})
+
+it('runs a real pnpm dependency script only after approval and retry', async () => {
+  const { manager, dir, profile } = await fixture('startup')
+  const addon = join(profile.cwd, 'addon')
+  mkdirSync(addon)
+  writeFileSync(join(addon, 'package.json'), JSON.stringify({ name: 'approval-fixture-addon', version: '1.0.0',
+    scripts: { install: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+  writeFileSync(join(addon, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n')
+  writeFileSync(join(addon, 'cordis.patch.yml'), '[]\n')
+  writeFileSync(join(dir, 'package.json'), '{"name":"approval-fixture","private":true}\n')
+  const policy = parseDocument(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))
+  policy.set('offline', true)
+  policy.set('storeDir', join(profile.cwd, 'store'))
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
+  const blocked = await manager.installBundle('file:./addon', { enabled: false })
+  expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } })
+  expect(blocked.pendingBuilds).toHaveLength(1)
+  const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt')
+  expect(existsSync(built)).toBe(false)
+  expect(readProfileManifest('test', dir).dependencies?.['approval-fixture-addon']).toBeUndefined()
+  const allowed = await manager.installBundle('file:./addon', { enabled: false, approvedBuilds: blocked.pendingBuilds! })
+  expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } })
+  expect(readFileSync(built, 'utf8')).toBe('built')
+})
+
+it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
+  const { manager, dir } = await fixture()
+  writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
+  const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
+  onTestFinished(() => { run.mockRestore() })
+  expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
+})
+
 it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
   const { manager, dir, ctx } = await fixture()
   const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
@@ -238,29 +312,6 @@ it('keeps saved changes after activation failure and allows a corrected configur
 })
 
 
-it('combines concurrent changes into durable notices without waking Agents', async () => {
-  const session = Session.create(SessionId('manager-notices'))
-  const wake = vi.fn()
-  const notices: UserMessage[] = []
-  const liveAgents = [{
-    inject(message: UserMessage) {
-      notices.push(message)
-      session.append('user/message', message, { surfaceOp: 'append' })
-    }, followup: wake, steer: wake,
-  }, { inject() { throw new Error('already disposed') } }]
-  const agents = { list: () => liveAgents }
-  const { manager } = await fixture('live', false, (ctx) => { ctx.provide('agents', agents as unknown as AgentRegistry) })
-  const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
-  await Promise.all([manager.setPluginEnabled(id, false), manager.setPluginEnabled(id, true)])
-  expect(notices).toHaveLength(1)
-  const noticeText = notices[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
-  expect(noticeText).toContain('"enabled":false')
-  expect(noticeText).toContain('"enabled":true')
-  expect(wake).not.toHaveBeenCalled()
-  expect(session.snapshotEvents().filter(row => row.type === 'user/message')).toHaveLength(1)
-})
-
-
 it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => {
   const { manager, dir, profile } = await fixture()
   writeFileSync(profile.installAnchor, '{}')
@@ -604,15 +655,6 @@ it('handles missing patch files and retains non-Error package diagnostics', asyn
   await expect(manager.setPluginEnabled(id, true)).rejects.toThrow()
 })
 
-it('bounds batched notices and discloses omitted operation results', async () => {
-  const messages: UserMessage[] = []
-  const { manager } = await fixture('live', false, (ctx) => {
-    ctx.provide('agents', { list: () => [{ inject: (message: UserMessage) => { messages.push(message) } }] } as unknown as AgentRegistry)
-  }, { outputBytes: 1, notificationDelayMs: 0 })
-  await manager.setBundleEnabled('extra', false)
-  expect(messages[0]?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')).toContain('"omitted":1')
-})
-
 it('applies a manager change through the active HMR service', async () => {
   const { manager } = await fixture()
   const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId

+ 2 - 0
packages/boot/plugin-manager/tests/tools.spec.ts

@@ -53,6 +53,8 @@ it('forwards all mutation actions and renders the returned outcome', async () =>
   expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', {})
   await call({ action: 'install_bundle', target: 'bundle', enabled: false })
   expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { enabled: false })
+  await call({ action: 'install_bundle', target: 'bundle', approvedBuilds: ['native'] })
+  expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { approvedBuilds: ['native'] })
   expect(resultText(await call({ action: 'remove_bundle', target: 'bundle' }))).toContain('"application":"failed"')
   expect(manager.removeBundle).toHaveBeenCalledWith('bundle')
 })

+ 1 - 0
packages/client/AGENTS.md

@@ -79,6 +79,7 @@ A dynamic browser half either carries a module privately or requests the shared
 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request.
 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol.
 5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles.
+6. **Package-local dynamic chunks are self-contained.** Source uses `import()` and the shared preset emits `require.async("./client.<name>.js")`; an entry or chunk must not retain a synchronous relative `require("./client*.js")`. Keep every static relative dependency inside its owning output chunk rather than relying on a sibling-chunk graph the runtime does not support.
 
 ### The module graph sits below cordis DI
 

+ 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: da7750e26827148d3bcd41395c50b98d6c8c6678
-README.zh.md: 9e1291dbd1e3c3ceb75504e4461cd41a3fe95ade
+README.md: e459da10332ffdb3af422b75f639ce25df512487
+README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311

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

@@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin
 
 ### Starting the reload chain
 
-Run `pnpm run dev:web` (or any tsdown watch process that writes the plugin's `lib/client.js`) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time.
+Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning.
 
 ### What a reload does
 
@@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove
 
 ### Design concept
 
-The Host half watches bundle artifacts and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. Artifact polling reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
+The Host half watches each package's stamped entry artifact and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. The entry bytes plus completed-build timestamp identify the revision; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
 
 ### The browser swap
 

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 启动重载链路
 
-对同一个宿主运行 `pnpm run dev:web`(或任何写入插件 `lib/client.js` 的 tsdown watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。
+对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。
 
 ### 一次重载做什么
 
@@ -59,7 +59,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-Host 半侧监听 bundle 产物并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。产物轮询报告重建 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
+Host 半侧监听每个包带完成标记的入口产物,并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。入口字节与构建完成时间戳共同标识 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理。
 
 ### 浏览器侧替换
 

+ 5 - 4
packages/client/hmr/src/index.ts

@@ -74,7 +74,7 @@ export function apply(ctx: Context, config: Config): 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.
+      // calls stay silent until the completed-build entry stamp changes.
       ctx.clientModules.rebuilt(id)
     } catch (error) {
       const code = (error as NodeJS.ErrnoException).code
@@ -101,7 +101,7 @@ export function apply(ctx: Context, config: Config): void {
       return
     }
     // The module host captured its baseline before reading the bytes in the
-    // startup batch. Only a mismatch crosses into the content-hash path.
+    // startup batch. Only a mismatch crosses into generation publication.
     if (!sameBundleStat(current, watch)) rehash(id, watch, current)
   }
 
@@ -116,8 +116,9 @@ export function apply(ctx: Context, config: Config): void {
         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.
+      // Stat-before-publication preserves a detectable older baseline for
+      // writes that land during the read. The preset stamps the entry after
+      // sibling chunks, so a completed build supplies the final stat change.
       rehash(id, watch, current)
     }
   }

+ 2 - 2
packages/client/modules/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/modules/README.md
-README.md: 1ea4976743eb0b396b02a765e51dec2a17f3c31b
-README.zh.md: 648af567b7d12f036ae50d5468cc88725bc052d5
+README.md: 5b751268430b716e7a0af24f8e25aaf2f865d079
+README.zh.md: 4152d64ca9dde60ab1aeb76c98508d8d86fcf54e

+ 3 - 3
packages/client/modules/README.md

@@ -35,7 +35,7 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
 
 ### What the browser loads
 
-The application combo scripts register plugin factories once during boot; module bodies remain lazy and run only at first import or materialization. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
+The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
 
 ### Live plugin composition
 
@@ -65,13 +65,13 @@ The package has two sides: the Node half is the composition and serving side (`c
 
 ### Lazy-CJS model
 
-Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. The synchronous `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record.
+Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. The synchronous `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record. Its `require.async` operation returns a Promise and fetches a compiler-generated package-local chunk before materializing that chunk once. This protocol supports self-contained chunks only: entry and chunk outputs cannot synchronously require another relative `client*.js` output.
 
 ### Incremental composition
 
 The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
 
-The Node half snapshots each client bundle before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, HMR hashes only a changed bundle, and combo revisions derive from the ordered row revisions. Advertised responses are immutable after first materialization, and an unknown combination or revision returns 404.
+The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads and caches that script, and its map remains uncomputed until the map URL is requested. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces. During development, the shared preset stamps `client.js` after every package output is written; HMR derives the next revision from the entry bytes and that completed-build stamp, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
 
 ### Boot manifest injection
 

+ 3 - 3
packages/client/modules/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-reference"
 
 ### 浏览器加载什么
 
-application combo 脚本在启动时仅注册一次插件 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
+application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
 
 ### 插件动态组合
 
@@ -65,13 +65,13 @@ application combo 脚本在启动时仅注册一次插件 factory;模块主体
 
 ### 惰性 CJS 模型
 
-执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。交给 factory 的同步 `require` 使用相同顺序,但不含异步图 row 加载,并把观察到的边记录到模块记录中。
+执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。交给 factory 的同步 `require` 使用相同顺序,但不含异步图 row 加载,并把观察到的边记录到模块记录中。它的 `require.async` 操作返回 Promise,并在一次性物化编译器生成的包内 chunk 前先获取该 chunk。该协议只支持自包含 chunk:入口与 chunk 产物不能同步 require 另一个相对 `client*.js` 产物。
 
 ### 增量组合
 
 Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
 
-Node 半侧会在发布前快照每个客户端 bundle,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,HMR 只哈希变化的 bundle,combo revision 从有序 row revision 派生。已公告响应在首次物化后保持不可变;未知组合或 revision 返回 404。
+Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会读取并缓存该脚本,其 map 仍会等到 map URL 被请求后才计算。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce。开发期间,共享预设会在该包所有输出写完后标记 `client.js`;HMR 从入口字节和该构建完成标记派生下一 revision,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
 
 ### 启动 manifest 注入
 

+ 17 - 7
packages/client/modules/src/client/manifest.ts

@@ -302,16 +302,26 @@ export function parseBootManifest(wire: unknown): BootManifest {
   return { rev: graph.rev, modules, plugins }
 }
 
+/** Module resolver passed into a registered Client bundle factory. */
+export interface ClientBundleRequire {
+  /** Resolve a module-table dependency synchronously. */
+  (specifier: string): unknown
+  /** Load and resolve a package-local dynamic chunk asynchronously. */
+  async(specifier: string): Promise<unknown>
+}
+
 /** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
 export interface ClientBundleRegistration {
   /** Plugin id (package name) — the registration key; must match the graph row being executed. */
   id: string
+  /** Package-local chunk filename; absent for the package's `client.js` entry. */
+  chunk?: string
   /**
-   * Closure factory holding the whole bundle body: receives the synchronous
-   * require bound to the module table and returns the bundle's exports. Runs
-   * once, at materialization.
+   * Closure factory holding the whole bundle body: receives the module-table
+   * require whose `async` operation loads generated chunks, and returns the
+   * bundle's exports. The factory runs once, at materialization.
    */
-  factory: (require: (spec: string) => unknown) => Record<string, unknown>
+  factory: (require: ClientBundleRequire) => Record<string, unknown>
 }
 
 /** Inputs passed by the web entry when it creates the client module system. */
@@ -376,7 +386,7 @@ export interface ClientModuleLoader {
   manifest: BootManifest
   /** Page-owned entry reconciliation, shared by boot, graph updates and HMR. */
   entries: ClientEntries
-  /** Materialized-module registry: id → record. The governance-side read API for entry exports. */
+  /** Materialized-module registry: entry or package-local chunk id → record. */
   loadCache: Map<string, ClientModuleRecord>
   /**
    * Internal contract consumed by the vendored Loader's `tree.import`. Resolves
@@ -400,8 +410,8 @@ export interface ClientModuleLoader {
    */
   prefetch(id: string): Promise<void>
   /**
-   * Full reset of one non-bootstrap module: drop its registered factory and
-   * materialized record so the next prefetch/import loads its one-resource
+   * Full reset of one non-bootstrap package: drop its entry and chunk factories
+   * and materialized records so the next prefetch/import loads its one-resource
    * combo script rather than the initial multi-resource request. The bootstrap
    * module remains materialized.
    * @param id - entry name to invalidate.

+ 87 - 18
packages/client/modules/src/client/system.ts

@@ -8,7 +8,7 @@ import { stripClientSuffix } from './manifest.ts'
 import { ClientEntries } from './entries.ts'
 import { removeOwnedStyles } from './entry-lifecycle.ts'
 import type {
-  BootManifest, BootModuleRow, ClientBundleRegistration, ClientModuleLoader, ClientModuleRecord,
+  BootManifest, BootModuleRow, ClientBundleRegistration, ClientBundleRequire, ClientModuleLoader, ClientModuleRecord,
   ClientModuleSystemOptions,
 } from './manifest.ts'
 
@@ -36,6 +36,28 @@ function atRevision(url: string, rev: string): string {
   return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
 }
 
+const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
+
+/** Internal module-table key for one package-local chunk. */
+function chunkId(ownerId: string, fileName: string): string {
+  return `${ownerId}/${fileName}`
+}
+
+/** Resolve a sibling chunk against the package's one-resource URL and current revision. */
+function chunkUrl(row: BootModuleRow, fileName: string, rev: string): string {
+  const url = atRevision(row.url, rev)
+  const marker = '/??'
+  const resourceStart = url.indexOf(marker)
+  const revisionStart = url.indexOf('&rev=', resourceStart + marker.length)
+  const resource = resourceStart < 0 || revisionStart < 0
+    ? undefined
+    : url.slice(resourceStart + marker.length, revisionStart)
+  if (resource !== `${row.id}/client.js`) {
+    throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`)
+  }
+  return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`
+}
+
 /**
  * Claim and inventory the <style> tags a factory injected during
  * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
@@ -71,6 +93,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
   private readonly bootstrapIds = new Set<string>()
   /** In-flight script transport per URL; every row in one batch shares it. */
   private readonly pendingArrival = new Map<string, Promise<void>>()
+  /** Owner generation captured by in-flight chunk requests and advanced on invalidation. */
+  private readonly generations = new Map<string, number>()
   /** Single-resource combo URL selected by HMR after invalidating one row. */
   private readonly reloadTargets = new Map<string, { url: string; rev: string }>()
   /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
@@ -122,13 +146,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
   private register(registration: ClientBundleRegistration): void {
-    const id = stripClientSuffix(registration.id)
+    const ownerId = stripClientSuffix(registration.id)
+    if (registration.chunk !== undefined && !CLIENT_CHUNK.test(registration.chunk)) {
+      throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`)
+    }
+    const id = registration.chunk === undefined ? ownerId : chunkId(ownerId, registration.chunk)
     if (this.bootstrapIds.has(id) || this.factories.has(id)) {
-      throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
+      const registrationName = registration.chunk === undefined ? registration.id : id
+      throw new Error(`client-modules: duplicate factory registration for "${registrationName}" (bundle executed twice without invalidate?)`)
     }
     this.factories.set(id, {
       factory: registration.factory,
-      rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
+      rev: this.reloadTargets.get(ownerId)?.rev ?? this.graphRows.get(ownerId)?.rev,
     })
   }
 
@@ -183,7 +212,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /** Materialize a registered factory (synchronous; memoized in loadCache). */
-  private materialize(id: string): ClientModuleRecord {
+  private materialize(id: string, ownerId = id): ClientModuleRecord {
     const existing = this.loadCache.get(id)
     if (existing !== undefined) return existing
     const registered = this.factories.get(id)
@@ -195,26 +224,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
     this.materializing.add(id)
     try {
       const edges = new Set<string>()
-      const exports = registered.factory(this.makeRequire(edges))
-      const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
+      const exports = registered.factory(this.makeRequire(ownerId, edges))
+      const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId), edges }
       this.loadCache.set(id, record)
       return record
     } catch (error) {
-      removeOwnedStyles(id)
+      removeOwnedStyles(ownerId)
       throw error
     } finally {
       this.materializing.delete(id)
     }
   }
 
-  /**
-   * The synchronous require answered to factories: seed → memoized record →
-   * registered factory. Fetching is async and therefore unreachable
-   * from here; an external dynamic package must have arrived before its
-   * consumer materializes.
-   */
-  private makeRequire(edges: Set<string>): (spec: string) => unknown {
-    return (spec: string): unknown => {
+  /** Build the synchronous module-table require and its asynchronous chunk operation. */
+  private makeRequire(ownerId: string, edges: Set<string>): ClientBundleRequire {
+    const require = (spec: string): unknown => {
       edges.add(spec)
       if (this.seed.has(spec)) return this.seed.get(spec)
       const id = stripClientSuffix(spec)
@@ -226,6 +250,46 @@ export class ClientModuleSystem implements ClientModuleLoader {
         + 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
       )
     }
+    require.async = async (spec: string): Promise<unknown> => {
+      edges.add(spec)
+      if (!spec.startsWith('./')) return await this.import(spec)
+      const fileName = spec.slice(2)
+      if (!CLIENT_CHUNK.test(fileName)) {
+        throw new Error(`client-modules: invalid relative chunk request ${JSON.stringify(spec)}`)
+      }
+      return await this.importChunk(ownerId, fileName)
+    }
+    return require
+  }
+
+  /** Load, register, and materialize one package-local dynamic chunk. */
+  private async importChunk(ownerId: string, fileName: string): Promise<unknown> {
+    const id = chunkId(ownerId, fileName)
+    const existing = this.loadCache.get(id)
+    if (existing !== undefined) return existing.exports
+    if (!this.factories.has(id)) {
+      const generation = this.generations.get(ownerId) ?? 0
+      const row = this.graphRows.get(ownerId)
+      if (row === undefined) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`)
+      /* v8 ignore next -- the final fallback needs an impossible graph-owned factory with no recorded revision. */
+      const revision = this.factories.get(ownerId)?.rev ?? this.reloadTargets.get(ownerId)?.rev ?? row.rev
+      const url = chunkUrl(row, fileName, revision)
+      let transport = this.pendingArrival.get(url)
+      if (transport === undefined) {
+        transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
+        this.pendingArrival.set(url, transport)
+      }
+      await transport
+      if ((this.generations.get(ownerId) ?? 0) !== generation) {
+        this.factories.delete(id)
+        this.loadCache.delete(id)
+        return await this.importChunk(ownerId, fileName)
+      }
+      if (!this.factories.has(id)) {
+        throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
+      }
+    }
+    return this.materialize(id, ownerId).exports
   }
 
   async import(specifier: string): Promise<unknown> {
@@ -297,12 +361,17 @@ export class ClientModuleSystem implements ClientModuleLoader {
   invalidate(id: string, rev?: string): void {
     const normalized = stripClientSuffix(id)
     if (this.bootstrapIds.has(normalized)) return
+    this.generations.set(normalized, (this.generations.get(normalized) ?? 0) + 1)
     const row = this.graphRows.get(normalized)
     if (row !== undefined) {
       const revision = rev ?? row.rev
       this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
     } else this.reloadTargets.delete(normalized)
-    this.factories.delete(normalized)
-    this.loadCache.delete(normalized)
+    for (const key of this.factories.keys()) {
+      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.factories.delete(key)
+    }
+    for (const key of this.loadCache.keys()) {
+      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.loadCache.delete(key)
+    }
   }
 }

+ 73 - 9
packages/client/modules/src/index.ts

@@ -146,6 +146,7 @@ interface ComboResource {
   id: string
   rev: string
   clientPath: string
+  fileName: string
   bundle: Buffer
 }
 
@@ -179,6 +180,8 @@ const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
 const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
 /** Debugger source name appended to page bundles in the WebWorker image. */
 const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
+/** Published package-local client chunk names accepted by the on-demand route. */
+const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
 
 /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
 function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
@@ -205,9 +208,9 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
   return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
 }
 
-/** Hash the executable artifact served after HMR observes one plugin change. */
-function artifactRevision(bundle: Buffer): string {
-  return framedHash('plugin-artifact', [bundle])
+/** Hash one completed build generation observed through its entry artifact. */
+function artifactRevision(bundle: Buffer, baseline: ClientArtifactBaseline): string {
+  return framedHash('plugin-artifact', [bundle, Buffer.from(String(baseline.mtimeMs))])
 }
 
 /** Address one ordered plugin-file list through the shared combo route. */
@@ -216,6 +219,11 @@ function comboUrl(ids: readonly string[], rev: string, sourceMap = false): strin
   return `/plugins/??${resources}&rev=${rev}`
 }
 
+/** Address one package-local chunk through the same revision as its entry. */
+function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
+  return `/plugins/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
+}
+
 /** Measure the longer map-form URL used to partition a startup resource list. */
 function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
   return Buffer.byteLength(comboUrl(
@@ -265,7 +273,7 @@ function prepareSource(resource: ComboResource): PreparedSource {
   source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
   if (!source.endsWith('\n')) source += '\n'
   const fallbackSource = sourceUrl === undefined
-    ? `/plugins/${resource.id}/client.js`
+    ? `/plugins/${resource.id}/${resource.fileName}`
     : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
   return { source, fallbackSource }
 }
@@ -365,6 +373,7 @@ function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: str
 function buildComboSourceMap(
   resources: readonly ComboResource[],
   sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
+  fileName = 'client.js',
 ): Buffer {
   const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
   let line = 0
@@ -383,7 +392,7 @@ function buildComboSourceMap(
     sections.push({ offset: { line, column: 0 }, map: section })
     line += newlineCount(`${prepared.source};\n`)
   }
-  return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
+  return Buffer.from(`${JSON.stringify({ version: 3, file: fileName, sections })}\n`)
 }
 
 /** Describe one combo and defer its executable and debug payloads independently. */
@@ -396,6 +405,7 @@ function buildCombo(
     id: record.entry.id,
     rev: record.entry.rev,
     clientPath: record.meta.clientPath,
+    fileName: 'client.js',
     bundle: record.bundle,
   }))
   const rev = revision ?? comboRevision(resources)
@@ -655,8 +665,8 @@ export class ClientModuleRegistry extends Service {
   }
 
   /**
-   * Re-hash one bundle (the HMR watch's registration hook — the only entry
-   * point through which bundle content changes reach the graph).
+   * Publish one completed bundle generation (the HMR watch's registration
+   * hook — the only entry point through which build changes reach the graph).
    * @param id - entry id (package name).
    * @returns the new rev, or undefined for an unknown id.
    */
@@ -665,7 +675,7 @@ export class ClientModuleRegistry extends Service {
     if (record === undefined) return undefined
     const baseline = this.captureArtifactBaseline(record.meta.clientPath)
     const bundle = readFileSync(record.meta.clientPath)
-    const rev = artifactRevision(bundle)
+    const rev = artifactRevision(bundle, baseline)
     record.baseline = baseline
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
@@ -746,6 +756,9 @@ export class ClientModuleRegistry extends Service {
         contentType: 'application/json; charset=utf-8',
       })
     }
+    for (const [resourceUrl, response] of this.responses) {
+      if (this.chunkRequest(new URL(resourceUrl, 'http://x')) !== undefined) responses.set(resourceUrl, response)
+    }
     this.previousBatchResponses = this.batchResponses
     this.batchResponses = batchResponses
     this.responses = responses
@@ -1026,6 +1039,55 @@ export class ClientModuleRegistry extends Service {
     this.notifyGraphChanged()
   }
 
+  /** Match an exact current-revision package-local chunk URL without reading its file. */
+  private chunkRequest(requestUrl: URL): {
+    record: WebPluginRecord
+    fileName: string
+    sourceMap: boolean
+    resourceUrl: string
+  } | undefined {
+    const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
+    for (const record of this.table.values()) {
+      const prefix = `/plugins/${record.entry.id}/`
+      if (!requestUrl.pathname.startsWith(prefix)) continue
+      const requested = requestUrl.pathname.slice(prefix.length)
+      const sourceMap = requested.endsWith('.map')
+      const fileName = sourceMap ? requested.slice(0, -'.map'.length) : requested
+      if (!CLIENT_CHUNK.test(fileName)) return undefined
+      if (resourceUrl !== chunkUrl(record.entry.id, fileName, record.entry.rev, sourceMap)) return undefined
+      return { record, fileName, sourceMap, resourceUrl }
+    }
+    return undefined
+  }
+
+  /** Build a package-local chunk response only when its URL is requested. */
+  private chunkResponse(requestUrl: URL): LazyResponse | undefined {
+    const request = this.chunkRequest(requestUrl)
+    if (request === undefined) return undefined
+    const { record, fileName, sourceMap, resourceUrl } = request
+    const clientPath = join(dirname(record.meta.clientPath), fileName)
+    if (!existsSync(clientPath)) return undefined
+    const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
+    const resource = (): ComboResource => ({
+      id: record.entry.id,
+      rev: record.entry.rev,
+      clientPath,
+      fileName,
+      bundle: readFileSync(clientPath),
+    })
+    const response: LazyResponse = sourceMap
+      ? {
+        body: lazyBody(() => buildComboSourceMap([resource()], this.readSourceMap, fileName)),
+        contentType: 'application/json; charset=utf-8',
+      }
+      : {
+        body: lazyBody(() => buildComboScript([resource()], sourceMapUrl)),
+        contentType: 'text/javascript; charset=utf-8',
+      }
+    this.responses.set(resourceUrl, response)
+    return response
+  }
+
   private async bundleResource(method: string | undefined, url: string): Promise<{
     status: number
     headers?: Record<string, string>
@@ -1034,7 +1096,9 @@ export class ClientModuleRegistry extends Service {
     if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
     const requestUrl = new URL(url, 'http://x')
     const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
-    const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
+    const response = this.responses.get(resourceUrl)
+      ?? this.previousBatchResponses.get(resourceUrl)
+      ?? this.chunkResponse(requestUrl)
     if (response !== undefined) {
       return {
         status: 200,

+ 159 - 0
packages/client/modules/tests/loader.client.spec.ts

@@ -12,6 +12,8 @@ const MODULES_ID = '@deepseek-ai/dsh-client-modules'
 
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
+const chunkUrl = (id: string, fileName: string, rev = '0'): string =>
+  `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
 const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
 const win = globalThis as DshWindow
@@ -71,6 +73,7 @@ function bench(
     gated?: string[]
     pending?: ClientBundleRegistration[]
     defaultTransport?: boolean
+    chunks?: Record<string, Factory | null>
   } = {},
 ): Bench {
   const fetched: string[] = []
@@ -82,6 +85,14 @@ function bench(
     if (opts.gated?.includes(url) === true) {
       await new Promise<void>((resolve) => { gates.set(url, resolve) })
     }
+    const sibling = /^\/plugins\/(.+)\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\?rev=[^&]+$/.exec(url)
+    if (sibling !== null) {
+      const id = sibling[1] as string
+      const chunk = sibling[2] as string
+      const factory = opts.chunks?.[`${id}/${chunk}`]
+      if (factory != null) win.__ModuleLoader__?.load({ id, chunk, factory })
+      return
+    }
     const batchIds = url === BOOTSTRAP_URL
       ? entries.filter(entry => entry.initialUrl === BOOTSTRAP_URL).map(entry => entry.id)
       : url === APPLICATION_URL
@@ -223,6 +234,111 @@ describe('lazy CJS arrival', () => {
     await b.loader.prefetch('a')
     expect(b.fetched).toHaveLength(1)
   })
+
+  it('loads a package-local dynamic chunk only when its factory requests it', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    }, {
+      chunks: { 'a/client.terminal.js': () => ({ marker: 'terminal' }) },
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<{ marker: string }> }
+    expect(b.fetched).toEqual([APPLICATION_URL])
+
+    const first = await entry.load()
+    const second = await entry.load()
+    expect(first).toBe(second)
+    expect(first).toEqual({ marker: 'terminal' })
+    expect(b.fetched).toEqual([APPLICATION_URL, chunkUrl('a', 'client.terminal.js')])
+  })
+
+  it('loads a package-local chunk with the revision that invalidated its entry', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    }, {
+      chunks: { 'a/client.terminal.js': () => ({ marker: 'terminal' }) },
+    })
+    const first = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await first.load()
+
+    b.loader.invalidate('a', 'rebuilt')
+    const second = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await second.load()
+    expect(b.fetched).toEqual([
+      APPLICATION_URL,
+      chunkUrl('a', 'client.terminal.js'),
+      comboUrl(['a'], 'rebuilt'),
+      chunkUrl('a', 'client.terminal.js', 'rebuilt'),
+    ])
+  })
+
+  it('answers a bare asynchronous request through the ordinary module import path', async () => {
+    const b = bench([row('a'), row('b')], {
+      a: req => ({ load: () => req.async('b') }),
+      b: () => ({ marker: 'b' }),
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).resolves.toEqual({ marker: 'b' })
+  })
+
+  it('materializes a parser-preloaded chunk without another transport', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.preloaded.js') }),
+    }, {
+      pending: [{ id: 'a', chunk: 'client.preloaded.js', factory: () => ({ marker: 'preloaded' }) }],
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).resolves.toEqual({ marker: 'preloaded' })
+    expect(b.fetched).toEqual([APPLICATION_URL])
+  })
+
+  it('shares one in-flight package-local chunk transport', async () => {
+    const url = chunkUrl('a', 'client.terminal.js')
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    }, {
+      gated: [url],
+      chunks: { 'a/client.terminal.js': () => ({ marker: 'terminal' }) },
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    const first = entry.load()
+    const second = entry.load()
+    expect(b.gates.has(url)).toBe(true)
+    b.gates.get(url)?.()
+    const [left, right] = await Promise.all([first, second])
+    expect(left).toBe(right)
+    expect(b.fetched.filter(fetched => fetched === url)).toHaveLength(1)
+  })
+
+  it('uses a pending replacement revision for a stale entry closure', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    }, {
+      chunks: { 'a/client.terminal.js': () => ({ marker: 'terminal' }) },
+    })
+    const stale = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    b.loader.invalidate('a', 'rebuilt')
+    await stale.load()
+    expect(b.fetched.at(-1)).toBe(chunkUrl('a', 'client.terminal.js', 'rebuilt'))
+  })
+
+  it('discards a chunk that arrives after its owner generation was invalidated', async () => {
+    const staleUrl = chunkUrl('a', 'client.terminal.js')
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    }, {
+      gated: [staleUrl],
+      chunks: { 'a/client.terminal.js': () => ({ marker: 'terminal' }) },
+    })
+    const staleEntry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    const staleLoad = staleEntry.load()
+    expect(b.gates.has(staleUrl)).toBe(true)
+    b.loader.invalidate('a', 'rebuilt')
+    await b.loader.import('a', '', {})
+    b.gates.get(staleUrl)?.()
+
+    await expect(staleLoad).resolves.toEqual({ marker: 'terminal' })
+    expect(b.fetched).toContain(chunkUrl('a', 'client.terminal.js', 'rebuilt'))
+  })
 })
 
 describe('require resolution', () => {
@@ -328,6 +444,49 @@ describe('failure modes', () => {
       .toThrow('duplicate factory registration for "x"')
   })
 
+  it('rejects malformed and duplicate chunk registrations', () => {
+    const b = bench([])
+    expect(() => { b.target.load({ id: 'a', chunk: '../bad.js', factory: () => ({}) }) })
+      .toThrow('invalid package-local chunk "../bad.js"')
+    b.target.load({ id: 'a', chunk: 'client.terminal.js', factory: () => ({}) })
+    expect(() => { b.target.load({ id: 'a', chunk: 'client.terminal.js', factory: () => ({}) }) })
+      .toThrow('duplicate factory registration for "a/client.terminal.js"')
+  })
+
+  it('rejects malformed relative chunk requests', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./terminal.js') }),
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).rejects.toThrow('invalid relative chunk request "./terminal.js"')
+  })
+
+  it('rejects a chunk request from a manually registered owner outside the boot graph', async () => {
+    const b = bench([])
+    b.target.load({
+      id: 'orphan',
+      factory: req => ({ load: () => req.async('./client.terminal.js') }),
+    })
+    const entry = await b.loader.import('orphan', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).rejects.toThrow('chunk owner "orphan" is not a boot graph entry')
+  })
+
+  it('rejects a graph row whose one-resource URL cannot address sibling chunks', async () => {
+    const b = bench([row('a', { url: '/plugins/a/client.js?rev=0' })], {
+      a: req => ({ load: () => req.async('./client.terminal.js') }),
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).rejects.toThrow('cannot resolve chunk "client.terminal.js"')
+  })
+
+  it('rejects a chunk script that does not register its generated id', async () => {
+    const b = bench([row('a')], {
+      a: req => ({ load: () => req.async('./client.missing.js') }),
+    })
+    const entry = await b.loader.import('a', '', {}) as { load: () => Promise<unknown> }
+    await expect(entry.load()).rejects.toThrow('loaded without registering "a/client.missing.js"')
+  })
+
   it('a bundle that never registers its id is loud', async () => {
     const b = bench([row('a')], { a: null })
     await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')

+ 59 - 1
packages/client/modules/tests/node-half.client.spec.ts

@@ -1,6 +1,6 @@
 /** Node-half composition diagnostics for package metadata and built client bundles. */
 
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { SourceMap } from 'node:module'
 import { tmpdir } from 'node:os'
@@ -20,6 +20,7 @@ const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
 const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
+const chunkUrl = (id: string, fileName: string, rev: string): string => `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
 const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
 
@@ -575,6 +576,11 @@ describe('client bundle activation', () => {
     const stableUrl = service.graph().entries.find(entry => entry.id === stablePackage)!.url
     const stableMapUrl = mapUrl(stableUrl)
     const first = await routeRequest(route, stableMapUrl)
+    const stableChunkPath = join(dirname(stablePath), 'client.stable.js')
+    writeFileSync(stableChunkPath, 'module.exports = { generation: 1 }\n')
+    const stableRow = service.graph().entries.find(entry => entry.id === stablePackage)!
+    const stableChunkUrl = chunkUrl(stablePackage, 'client.stable.js', stableRow.rev)
+    const firstChunk = await routeRequest(route, stableChunkUrl)
 
     writeFileSync(`${stablePath}.map`, JSON.stringify({
       version: 3,
@@ -582,10 +588,12 @@ describe('client bundle activation', () => {
       mappings: 'AAAA',
       sources: ['src/second.ts'],
     }))
+    writeFileSync(stableChunkPath, 'module.exports = { generation: 2 }\n')
     writeFileSync(rebuiltPath, 'module.exports = { rebuilt: true }\n')
     service.rebuilt(rebuiltPackage)
 
     expect((await routeRequest(route, stableMapUrl)).body).toEqual(first.body)
+    expect((await routeRequest(route, stableChunkUrl)).body).toEqual(firstChunk.body)
   })
 
   it('maps packed combo sections back to each generated client bundle', async () => {
@@ -780,6 +788,56 @@ describe('client bundle activation', () => {
     })
   })
 
+  it('serves a package-local chunk only after its versioned URL is requested', async () => {
+    const packageName = '@fixture/chunked'
+    const clientPath = writePackage(packageName)
+    const chunkPath = join(dirname(clientPath), 'client.terminal.js')
+    mkdirSync(dirname(clientPath), { recursive: true })
+    writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
+    const { service, route } = constructWithRoute([packageName])
+    const row = service.graph().entries[0]!
+
+    const startup = await routeRequest(route, row.url)
+    expect(startup.status).toBe(200)
+    expect(startup.body.toString('utf8')).not.toContain('terminal loaded')
+    expect((await routeRequest(route, chunkUrl(packageName, 'client.terminal.js', row.rev))).status).toBe(404)
+
+    writeFileSync(chunkPath, 'module.exports = { marker: "terminal loaded" }\n')
+    const url = chunkUrl(packageName, 'client.terminal.js', row.rev)
+    const head = await routeRequest(route, url, 'HEAD')
+    expect(head.status).toBe(200)
+    expect(head.body).toHaveLength(0)
+    const chunk = await routeRequest(route, url)
+    expect(chunk.status).toBe(200)
+    expect(chunk.body.toString('utf8')).toContain('terminal loaded')
+    expect(chunk.body.toString('utf8')).toContain(`sourceMappingURL=${url.replace('.js?', '.js.map?')}`)
+    expect((await routeRequest(route, url.replace('.js?', '.js.map?'))).status).toBe(200)
+    expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
+  })
+
+  it('publishes a new chunk revision when a completed build rewrites only the entry timestamp', async () => {
+    const packageName = '@fixture/chunk-only-rebuild'
+    const clientPath = writePackage(packageName)
+    const chunkPath = join(dirname(clientPath), 'client.terminal.js')
+    mkdirSync(dirname(clientPath), { recursive: true })
+    writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
+    writeFileSync(chunkPath, 'module.exports = { generation: 1 }\n')
+    const { service, route } = constructWithRoute([packageName])
+    const firstRow = service.graph().entries[0]!
+    const firstUrl = chunkUrl(packageName, 'client.terminal.js', firstRow.rev)
+    expect((await routeRequest(route, firstUrl)).body.toString('utf8')).toContain('generation: 1')
+
+    writeFileSync(chunkPath, 'module.exports = { generation: 2 }\n')
+    const entryStat = statSync(clientPath)
+    const completed = new Date(entryStat.mtimeMs + 1_000)
+    utimesSync(clientPath, entryStat.atime, completed)
+    const nextRev = service.rebuilt(packageName)!
+    expect(nextRev).not.toBe(firstRow.rev)
+    expect((await routeRequest(route, firstUrl)).status).toBe(404)
+    const nextUrl = chunkUrl(packageName, 'client.terminal.js', nextRev)
+    expect((await routeRequest(route, nextUrl)).body.toString('utf8')).toContain('generation: 2')
+  })
+
   it('applies sourceRoot before relocating absolute-looking section sources', async () => {
     const packageName = '@fixture/source-root'
     const clientPath = writePackage(packageName)

+ 52 - 7
packages/client/tsdown.client.ts

@@ -10,12 +10,12 @@
  * Non-experimental client outputs reject experimental module and stylesheet
  * inputs, including origins recorded by chained source maps.
  */
-import { readFile } from 'node:fs/promises'
+import { readFile, stat, utimes } from 'node:fs/promises'
 import { existsSync, globSync, readFileSync } from 'node:fs'
 import { createRequire, isBuiltin } from 'node:module'
 import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type { TsdownPlugin, UserConfig } from 'tsdown'
+import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
 import { transform } from 'lightningcss'
 import { optionalStringArray } from './modules/src/client/manifest.ts'
 import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
@@ -104,7 +104,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
  * @param libEntry - node-half entries, spelled at the call site so the
  * package-invariants gate can see `lib/types/invariant.js` in each package's
  * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
- * @param options - phase placement, lib overrides, and companion Node configs.
+ * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
  * @returns ENV-selected tsdown config for the current build face.
  */
 export function clientBundle(
@@ -116,7 +116,7 @@ export function clientBundle(
   return ({ env }) => {
     const face = buildFace(env?.DSH_BUILD_FACE)
     const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
-    const client = clientConfig(id, clientEntry)
+    const client = clientConfig(id, clientEntry, options.clientBanner)
     const node = [lib, ...(options.companions ?? [])]
     if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
     if (face === 'client') {
@@ -205,6 +205,8 @@ interface ClientBundleOptions {
   readonly companions?: readonly UserConfig[]
   /** Overrides for the package's primary Node-side library config. */
   readonly lib?: UserConfig
+  /** Optional legal or attribution text selected by emitted client filename. */
+  readonly clientBanner?: (fileName: string) => string | undefined
 }
 
 type BuildFace = 'host' | 'client' | undefined
@@ -432,7 +434,43 @@ function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boole
   return patterns.some(pattern => pattern.test(specifier))
 }
 
-function clientConfig(id: string, entry: string): UserConfig {
+/** Render package-local dynamic imports through the Client module loader's asynchronous operation. */
+function asyncChunkRequirePlugin(): TsdownPlugin {
+  return {
+    name: 'dsh-client-async-chunk-require',
+    renderChunk(code, chunk, outputOptions) {
+      if (outputOptions.format !== 'cjs') return null
+      const transformed = new Rolldown.RolldownMagicString(code)
+      for (const dynamicImport of chunk.dynamicImports) {
+        const fileName = dynamicImport.startsWith('./') ? dynamicImport.slice(2) : dynamicImport
+        if (!/^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/.test(fileName)) continue
+        const specifier = `./${fileName}`
+        const call = new RegExp(
+          `Promise\\.resolve\\(\\)\\.then\\(\\(\\)\\s*=>\\s*require\\((['"])${escapeSpecifier(specifier)}\\1\\)\\)`,
+          'gu',
+        )
+        const matches = [...code.matchAll(call)]
+        if (matches.length === 0) {
+          throw new Error(`client bundle compiler: dynamic chunk ${JSON.stringify(specifier)} has no generated import expression`)
+        }
+        for (const match of matches) {
+          transformed.overwrite(match.index, match.index + match[0].length, `require.async(${JSON.stringify(specifier)})`)
+        }
+      }
+      return transformed.hasChanged() ? transformed : null
+    },
+    async writeBundle(outputOptions, bundle) {
+      const entry = Object.values(bundle).find(output => output.type === 'chunk' && output.isEntry)
+      if (entry === undefined || outputOptions.dir === undefined) return
+      const entryPath = resolvePath(outputOptions.dir, entry.fileName)
+      const current = await stat(entryPath)
+      const completedAt = new Date(Math.max(Date.now(), current.mtimeMs + 1))
+      await utimes(entryPath, current.atime, completedAt)
+    },
+  }
+}
+
+function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
   const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
   const isolation = clientInputIsolation(id)
   return {
@@ -506,7 +544,7 @@ function clientConfig(id: string, entry: string): UserConfig {
           + '(type-only imports are erased and never reach this gate)',
         )
       },
-    }, tscSourceMapPlugin(), isolation.plugin, {
+    }, tscSourceMapPlugin(), asyncChunkRequirePlugin(), isolation.plugin, {
       name: 'dsh-css-modules-inline',
       resolveId(source: string, importer: string | undefined) {
         if (!source.endsWith('.module.css')) return null
@@ -565,6 +603,9 @@ function clientConfig(id: string, entry: string): UserConfig {
     }],
     outputOptions: {
       entryFileNames: 'client.js',
+      // The imported source basename becomes the published chunk name; package
+      // files lists and artifact tests pin every intentional chunk.
+      chunkFileNames: 'client.[name].js',
       sourcemapExcludeSources: false,
       // The map is served from /plugins/<scoped-package>/client.js.map. The
       // browser resolves its local sources back into URLs that mirror the
@@ -574,7 +615,11 @@ function clientConfig(id: string, entry: string): UserConfig {
         isolation.sourcePath(source, mapPath)
         return browserSourcePath(source, mapPath)
       },
-      banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
+      banner: (chunk) => {
+        const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`
+        const prefix = clientBanner?.(chunk.fileName)
+        return prefix === undefined ? registration : `${prefix}\n${registration}`
+      },
       footer: 'return module.exports; } });',
       intro: 'var module = { exports: {} }; var exports = module.exports;',
     },

+ 2 - 0
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -138,6 +138,7 @@ export const zh = {
   reasonNotRemovable: '这个包不属于当前 profile,或者是插件管理所需的组件',
   reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载',
   reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们',
+  reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新',
   reasonOperationError: 'Host 报告了一个错误',
 } satisfies Record<string, string>
 
@@ -282,5 +283,6 @@ export const en = {
   reasonNotRemovable: 'This package is not owned by the profile, or plugin management needs it.',
   reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.',
   reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.',
+  reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.',
   reasonOperationError: 'The Host reported an error.',
 } satisfies Record<PluginManagerLocaleKey, string>

+ 1 - 0
packages/client/ui-plugin-manager/src/client/presentation.ts

@@ -19,6 +19,7 @@ const CODE_KEYS = {
   'not-removable': 'reasonNotRemovable',
   'stop-profile': 'reasonStopProfile',
   'bundle-in-use': 'reasonBundleInUse',
+  'stale-approval': 'reasonStaleApproval',
   'operation-error': 'reasonOperationError',
 } satisfies Record<ManagementError['code'], PluginManagerLocaleKey>
 

+ 2 - 2
packages/client/ui-settings-plugin-inventory/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-plugin-inventory/README.md
-README.md: 311944784b64eb57d4321acb0c3701ef084da8cb
-README.zh.md: e284c16b13f025d4448a1a74ae9235a702cce37d
+README.md: a82f177b2a0a463a395cef470b526ea3117625fd
+README.zh.md: c6395d015a7c60b0b0738eae96ae99fe1135cedc

+ 2 - 0
packages/client/ui-settings-plugin-inventory/README.md

@@ -27,6 +27,8 @@ The **Plugin list** tab lets Web users inspect plugins without changing their co
 
 Open the Plugins section in Settings and select the **Plugin list** tab to inspect the Host's plugin inventory. The tab reads no Remote during plugin activation — selecting it for the first time mounts the component and lazily calls `ctx.remote.pluginInventory.list()` through `api-remotes`.
 
+A failed installation with pending pnpm build permissions offers **Allow these scripts and retry**. The action displays the exact package names and persistent permission scope, then retries the original package spec and activation choice. One click approves the entire displayed group, which can include pending packages from earlier attempts. Closing the page grants no permission.
+
 ### Reading a card
 
 Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; enabled entries also show a colored root-fiber status dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.

+ 2 - 0
packages/client/ui-settings-plugin-inventory/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 打开设置中的「插件」分区并选择**插件列表**标签页,即可查看宿主的插件清单。插件激活期间不会读取 Remote——首次选择该标签页时才挂载组件,并通过 `api-remotes` 懒调用 `ctx.remote.pluginInventory.list()`。
 
+安装失败且存在待审批的 pnpm 构建权限时,页面提供“允许这些脚本并重试”。该操作展示准确包名和持久授权范围,再使用原来的包规格及启用选项重试。一次点击批准整个列表,其中可能包括此前安装留下的待审批包。关闭页面不会授予权限。
+
 ### 阅读卡片
 
 每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。

+ 2 - 2
packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md
-README.md: 3da629129f36d18a20eef74be8f96a1455d532f3
-README.zh.md: 6f1eb7eef1f690ebdfdf38cfdd1f62b1314d7d66
+README.md: d4e114aa6e12171d61f8a5284fdbe923916fe0d9
+README.zh.md: 32cb704d29fd0009fdf7a51235617c25029295f0

+ 2 - 1
packages/client/ui-sidebar-documentpreview/README.md

@@ -53,7 +53,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
 
 Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
 
-Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
+Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
 
 <a id="navigation"></a>
 ## Navigation
@@ -78,6 +78,7 @@ No direct effect; what the user reads here never enters a model request.
 - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
 - **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
 - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
+- **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 2 - 1
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -53,7 +53,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
 
 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
 
-首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
+首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
 
 <a id="navigation"></a>
 ## 导航
@@ -78,6 +78,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
 - **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
+- **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 1 - 0
packages/client/ui-sidebar-documentpreview/package.json

@@ -73,6 +73,7 @@
   "files": [
     "lib/index.js",
     "lib/client.js",
+    "lib/client.*.js",
     "lib/types/**/*.d.ts"
   ]
 }

+ 18 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx

@@ -0,0 +1,18 @@
+/** Load the PDF renderer only after a PDF body is mounted. */
+import { lazy, Suspense, type ReactNode } from 'react'
+import { LoadingIndicator } from '../LoadingIndicator.tsx'
+import css from '../TextPreview.module.css'
+import type { PdfBodyProps } from './pdf.tsx'
+
+const LoadedPdfBody = lazy(async () => ({ default: (await import('./pdf.tsx')).PdfBody }))
+
+/**
+ * Suspend while the package-local PDF chunk arrives.
+ * @param props - PDF body props supplied by the document slot.
+ * @returns the deferred PDF renderer.
+ */
+export function LazyPdfBody(props: PdfBodyProps): ReactNode {
+  return <Suspense fallback={<LoadingIndicator className={css.status} label={props.t('loading')} />}>
+    <LoadedPdfBody {...props} />
+  </Suspense>
+}

+ 14 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -54,3 +54,17 @@
   width: 100%;
   min-height: 100%;
 }
+
+.loadingIcon {
+  display: flex;
+  flex: none;
+  animation: turn 0.8s linear infinite;
+}
+
+@keyframes turn {
+  to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .loadingIcon { animation: none; }
+}

+ 3 - 2
packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts

@@ -2,7 +2,8 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '../index.ts'
 import type { DocumentPreviewDefinition } from '../document/registry.ts'
-import { PdfBody, type PdfBodyInjected } from './PdfBody.tsx'
+import type { PdfBodyInjected } from './pdf.tsx'
+import { LazyPdfBody } from './LazyPdfBody.tsx'
 import { createPdfStore } from './store.ts'
 import { en, zh } from './locales.ts'
 
@@ -43,5 +44,5 @@ export function apply(ctx: Context): void {
         signal.addEventListener('abort', forget, { once: true })
       },
     }),
-  }, PdfBody)))
+  }, LazyPdfBody)))
 }

+ 8 - 5
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx → packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx

@@ -1,12 +1,10 @@
 /** PDF page presentation; binary content and tab information come from the document owner. */
 import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
-import clsx from 'clsx'
-import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { DocumentPreviewProps } from '../document/contract.ts'
-import { LoadingIndicator } from '../LoadingIndicator.tsx'
-import { DEFAULT_PDF_VIEW, type PdfStore } from './store.ts'
+import type { PdfStore, PdfView } from './store.ts'
 import { renderPdfPage, type PdfDocument } from './document.ts'
 import { openPdf } from './runtime.ts'
 import { PdfWorkerFailure } from './errors.ts'
@@ -30,6 +28,8 @@ type LoadState =
   | { readonly kind: 'loaded'; readonly data: Uint8Array<ArrayBuffer>; readonly document: PdfDocument }
   | { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly error: unknown }
 
+const DEFAULT_PDF_VIEW: PdfView = { page: 1 }
+
 /**
  * Present a PDF with tab-local viewing preferences and component-owned rendering resources.
  * @param props - complete bytes and framework-owned tab/store/locale seats.
@@ -67,7 +67,10 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
   if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
   // The open wait centres like the owner's read spinner before it, so one
   // spinner position covers everything until the first page block appears.
-  if (load?.data !== data) return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
+  if (load?.data !== data) return <span className={`${css.status} ${css.opening}`} role="status"
+    aria-label={t('loading')} data-document-loading>
+    <span className={css.loadingIcon} aria-hidden="true"><IconLoadingOutline16 /></span>
+  </span>
   if (load.kind === 'failed') {
     return <div className={css.status} role="alert">
       <span>{failureText(load.error, t)}</span>

+ 0 - 3
packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts

@@ -7,9 +7,6 @@ export interface PdfView {
   readonly page: number
 }
 
-/** Initial viewing position before a tab reaches another page. */
-export const DEFAULT_PDF_VIEW: PdfView = { page: 1 }
-
 /** Page state isolated by the owning tab record. */
 export interface PdfState {
   byTab: Record<TabId, PdfView>

+ 2 - 2
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -23,7 +23,7 @@ import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
 import { HTML_BODY_ID } from '../src/client/html/index.ts'
 import { ImageBody } from '../src/client/image/ImageBody.tsx'
 import { IMAGE_BODY_ID } from '../src/client/image/index.ts'
-import { PdfBody } from '../src/client/pdf/PdfBody.tsx'
+import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
 import { PDF_BODY_ID } from '../src/client/pdf/index.ts'
 import { CodeBody } from '../src/client/code/CodeBody.tsx'
 import { en, zh } from '../src/client/locales.ts'
@@ -96,7 +96,7 @@ describe('ui-sidebar-documentpreview apply', () => {
       ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
       ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],
       ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
-      ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', PdfBody],
+      ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', LazyPdfBody],
       ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
     ])
     expect(registered[0]?.store).toBeDefined()

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx

@@ -12,7 +12,7 @@ import type { openPdf } from '../src/client/pdf/runtime.ts'
 const engine = vi.hoisted(() => ({ open: vi.fn<typeof openPdf>(), render: vi.fn<typeof renderPdfPage>() }))
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: engine.open }))
 vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render }))
-import { PdfBody, type PdfBodyProps } from '../src/client/pdf/PdfBody.tsx'
+import { PdfBody, type PdfBodyProps } from '../src/client/pdf/pdf.tsx'
 import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
 import { en } from '../src/client/pdf/locales.ts'
 import { PdfWorkerFailure } from '../src/client/pdf/errors.ts'

+ 14 - 3
packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts

@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
 
 const packageRoot = resolve(import.meta.dirname, '..')
 const bundlePath = join(packageRoot, 'lib/client.js')
+const pdfChunkPath = join(packageRoot, 'lib/client.pdf.js')
 const require = createRequire(import.meta.url)
 const licenseNames = [
   'LICENSE',
@@ -41,22 +42,32 @@ function runPnpm(args: string[], cwd: string, timeout: number): string {
 }
 
 describe('published PDF.js licenses', () => {
-  it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed client artifact', ({ task }) => {
+  it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed PDF chunk', ({ task }) => {
+    expect(existsSync(pdfChunkPath)).toBe(true)
     const output = mkdtempSync(join(tmpdir(), 'dsh-document-preview-pack-'))
     try {
       const packed = JSON.parse(runPnpm([
         'pack', '--json', '--pack-destination', output,
       ], packageRoot, task.timeout)) as { filename: string; files: { path: string }[] }
       expect(packed.files.map(file => file.path)).toContain('lib/client.js')
+      expect(packed.files.map(file => file.path)).toContain('lib/client.pdf.js')
       expect(packed.files.some(file => file.path.endsWith('pdfjs-NOTICES.txt'))).toBe(false)
 
       const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout)
-      expect(client).toContain('//! Bundled PDF.js license notices')
+      const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout)
+      expect([...client.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
+        .toEqual(['./client.pdf.js'])
+      expect(client).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
+      expect([...pdf.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
+        .toEqual([])
+      expect(client).not.toContain('//! Bundled PDF.js license notices')
+      expect(client).not.toContain('/pdfjs-dist/')
+      expect(pdf).toContain('//! Bundled PDF.js license notices')
       const pdfRoot = dirname(require.resolve('pdfjs-dist/package.json'))
       for (const name of licenseNames) {
         const source = readFileSync(join(pdfRoot, name), 'utf8').trimEnd()
         const commented = [`// ${name}`, '// ', ...source.split('\n').map(line => `// ${line}`)].join('\n')
-        expect(client, `${name} must be visible in package/lib/client.js`).toContain(commented)
+        expect(pdf, `${name} must be visible in package/lib/client.pdf.js`).toContain(commented)
       }
     } finally {
       rmSync(output, { recursive: true, force: true })

+ 17 - 4
packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts

@@ -1,19 +1,32 @@
+// @vitest-environment jsdom
 /** PDF metadata, keyed slot, dictionary, and tab-view lifetime registration. */
+import { createElement } from 'react'
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
 import { createPdfStore } from '../src/client/pdf/store.ts'
-import type { PdfBodyInjected } from '../src/client/pdf/PdfBody.tsx'
+import type { PdfBodyInjected } from '../src/client/pdf/pdf.tsx'
 
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: vi.fn() }))
+const renderedPdf = vi.hoisted(() => vi.fn(() => null))
+vi.mock('../src/client/pdf/pdf.tsx', () => ({ PdfBody: renderedPdf }))
 import { apply, PDF_BODY_ID } from '../src/client/pdf/index.ts'
-import { PdfBody } from '../src/client/pdf/PdfBody.tsx'
+import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
 import { en, zh } from '../src/client/pdf/locales.ts'
 
+afterEach(() => { cleanup(); renderedPdf.mockClear() })
+
 describe('PDF registration', () => {
+  it('shows localized loading feedback while the PDF chunk resolves', async () => {
+    render(createElement(LazyPdfBody, { t: makeTranslate(en) } as never))
+    expect(screen.getByRole('status', { name: en.loading })).toBeDefined()
+    await waitFor(() => { expect(renderedPdf).toHaveBeenCalledOnce() })
+  })
+
   it('registers a builtin complete-bytes body and removes all contributions and retained view state on dispose', async () => {
     const ctx = new Context()
     const previews = new DocumentPreviewRegistry()
@@ -26,7 +39,7 @@ describe('PDF registration', () => {
       inject: (sessionId: SessionId, actions: ReturnType<ReturnType<typeof createPdfStore>['create']>['actions']) => PdfBodyInjected
     }> = []
     const register = vi.fn((options: typeof entries[number], component: unknown) => {
-      expect(component).toBe(PdfBody)
+      expect(component).toBe(LazyPdfBody)
       entries.push(options)
       return () => { entries.splice(entries.indexOf(options), 1) }
     })

+ 3 - 2
packages/client/ui-sidebar-documentpreview/tsdown.config.ts

@@ -4,7 +4,9 @@ import { dirname, join } from 'node:path'
 import type { UserConfig } from 'tsdown'
 import { clientBundle } from '../tsdown.client.ts'
 
-const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'])
+const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], {
+  clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : undefined,
+})
 const require = createRequire(import.meta.url)
 const workerSpecifier = 'pdfjs-dist/build/pdf.worker.min.mjs?raw'
 const workerModule = '\0dsh-pdf-worker.mjs'
@@ -54,7 +56,6 @@ const pdfWorker: NonNullable<UserConfig['plugins']> = [{
 export default (options: Parameters<typeof bundle>[0]): UserConfig[] => bundle(options).map(config =>
   config.name?.endsWith('/client') === true ? {
     ...config,
-    banner: pdfLicenseBanner(),
     plugins: [config.plugins, pdfWorker],
     define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() },
   } : config,

+ 2 - 2
packages/client/ui-sidebar-terminal/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-sidebar-terminal/README.md
-README.md: 04224d2ef8ff41df7c56bfb2ece130f03a790873
-README.zh.md: 80ea0373e22e888194a2d36ddebe330d1222183b
+README.md: 37c3706996a7083c14f10e1b29603698f6dc8689
+README.zh.md: 45563a1d717ff51ffe371a6a894e4376466c6d7d

+ 2 - 1
packages/client/ui-sidebar-terminal/README.md

@@ -41,7 +41,7 @@ The terminal background, default text, cursor, and selection follow the DSH them
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
+This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
 
 A Session header contribution queries Host terminals and opens only those without an existing tab association. The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work.
 
@@ -73,6 +73,7 @@ None; terminal output travels only between the browser and Host.
 - Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine.
 - Application OSC color overrides are retained by the mounted renderer; a newly opened renderer cannot recover them from the Host screen snapshot.
 - Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart.
+- A failed terminal chunk load requires a page reload because React caches a rejected lazy import for the page lifetime.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 2 - 1
packages/client/ui-sidebar-terminal/README.zh.md

@@ -41,7 +41,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节</summary>
 
-插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
+插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
 
 Session header contribution 查询 Host 终端,只打开没有现有标签关联的终端。终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
 
@@ -73,6 +73,7 @@ Session header contribution 查询 Host 终端,只打开没有现有标签关
 - 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。
 - 应用的 OSC 颜色覆盖由已挂载的渲染器保留;新打开的渲染器无法从 Host 屏幕快照恢复这些颜色。
 - 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。
+- Terminal chunk 加载失败后需要刷新页面,因为 React 会在页面生命周期内缓存被拒绝的 lazy import。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 1 - 0
packages/client/ui-sidebar-terminal/package.json

@@ -73,6 +73,7 @@
   "files": [
     "lib/index.js",
     "lib/client.js",
+    "lib/client.*.js",
     "lib/types/**/*.d.ts"
   ]
 }

+ 14 - 0
packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx

@@ -0,0 +1,14 @@
+/** Load xterm only after a terminal body is mounted. */
+import { lazy, Suspense, type ReactNode } from 'react'
+import type { TerminalBodyProps } from './terminal.tsx'
+
+const LoadedTerminalBody = lazy(async () => ({ default: (await import('./terminal.tsx')).TerminalBody }))
+
+/**
+ * Suspend while the package-local terminal chunk arrives.
+ * @param props - Terminal body props supplied by the sidebar slot.
+ * @returns the deferred terminal renderer.
+ */
+export function LazyTerminalBody(props: TerminalBodyProps): ReactNode {
+  return <Suspense fallback={null}><LoadedTerminalBody {...props} /></Suspense>
+}

+ 2 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css

@@ -0,0 +1,2 @@
+.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }

+ 1 - 1
packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx

@@ -4,7 +4,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
 import { TerminalIcon } from './TerminalIcon.tsx'
 import type { TerminalInjected } from './face.ts'
 import type {} from './locales.ts'
-import css from './TerminalBody.module.css'
+import css from './TerminalTitle.module.css'
 
 /**
  * Render the terminal name, editable in place on double-click.

+ 2 - 2
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
 import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx'
-import { TerminalBody } from './TerminalBody.tsx'
+import { LazyTerminalBody } from './LazyTerminalBody.tsx'
 import { TerminalTitle } from './TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
@@ -76,7 +76,7 @@ export function apply(ctx: Context): void {
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab', key: id, locale: namespace,
       inject: (sessionId): TerminalBodyInjected => ({ ...inject(sessionId), hooks: { theme } }),
-    }, TerminalBody,
+    }, LazyTerminalBody,
   )), 'ui-sidebar-terminal.body')
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle,

+ 0 - 2
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css → packages/client/ui-sidebar-terminal/src/client/terminal.module.css

@@ -1,6 +1,4 @@
 .root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; }
-.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }
 .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
 .screen :global(.xterm) { height: 100%; }
 .screen :global(.xterm-viewport) { background: var(--dsw-alias-bg-base); }

+ 5 - 3
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx → packages/client/ui-sidebar-terminal/src/client/terminal.tsx

@@ -8,9 +8,8 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
 import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import type { TerminalBodyInjected } from './face.ts'
 import type {} from './locales.ts'
-import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import '@xterm/xterm/css/xterm.css'
-import css from './TerminalBody.module.css'
+import css from './terminal.module.css'
 import { TerminalTheme } from './terminal-theme.ts'
 import { observeTerminalCursor } from './terminal-cursor.ts'
 
@@ -36,7 +35,10 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter
   </Button>
   if (state.issue === 'missingTerminal') return <section className={css.root} data-sidebar-terminal>
     <div className={css.empty}>
-      <TerminalGuideIcon size={36} />
+      <svg width="36" height="36" viewBox="0 0 28 28" fill="none" aria-hidden="true">
+        <rect x="3" y="5" width="22" height="19" rx="3" fill="#17191d" />
+        <path d="m8 10 4 4-4 4M15 18h5" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
+      </svg>
       <p className={css.emptyMessage} role="alert">{t('missingTerminal')}</p>
       {newTerminal}
     </div>

+ 13 - 3
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -3,8 +3,9 @@
 import { createElement } from 'react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { renderToStaticMarkup } from 'react-dom/server'
+import { cleanup, render, waitFor } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
-import { expect, it, vi } from 'vitest'
+import { afterEach, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
@@ -13,7 +14,7 @@ import type { SidebarRightOpenTab } from '@deepseek-ai/dsh-client-ui-sidebar-rig
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
-import { TerminalBody } from '../src/client/TerminalBody.tsx'
+import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx'
@@ -21,6 +22,10 @@ import type { TerminalBodyInjected } from '../src/client/face.ts'
 import { en, zh } from '../src/client/locales.ts'
 
 vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() }))
+const renderedTerminal = vi.hoisted(() => vi.fn(() => null))
+vi.mock('../src/client/terminal.tsx', () => ({ TerminalBody: renderedTerminal }))
+
+afterEach(() => { cleanup(); renderedTerminal.mockClear() })
 
 const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null })
 
@@ -89,7 +94,7 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh })
     expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([
       ['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'],
-      ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'],
+      ['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'],
       ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'],
       ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'],
       ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],
@@ -140,6 +145,11 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
   expect(h.dictionaries.size).toBe(0)
 })
 
+it('loads the terminal body implementation when its registered wrapper mounts', async () => {
+  render(createElement(LazyTerminalBody, {} as never))
+  await waitFor(() => { expect(renderedTerminal).toHaveBeenCalledOnce() })
+})
+
 it('restores terminal occurrences before listing unrepresented Host terminals and shares recovery across headers', async () => {
   const h = await mountPlugin()
   h.tabsIn.mockReturnValue([{ id: 'collapsed-terminal', kind: 'terminal' }, { id: 'file', kind: 'text' }])

+ 22 - 0
packages/client/ui-sidebar-terminal/tests/bundle-split.client.spec.ts

@@ -0,0 +1,22 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const packageRoot = resolve(import.meta.dirname, '..')
+const entryPath = join(packageRoot, 'lib/client.js')
+const terminalPath = join(packageRoot, 'lib/client.terminal.js')
+
+describe('terminal client artifacts', () => {
+  it.skipIf(!existsSync(entryPath))('keeps xterm outside the startup bundle', () => {
+    expect(existsSync(terminalPath)).toBe(true)
+    const entry = readFileSync(entryPath, 'utf8')
+    const terminal = readFileSync(terminalPath, 'utf8')
+    expect([...entry.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
+      .toEqual(['./client.terminal.js'])
+    expect(entry).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
+    expect([...terminal.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
+      .toEqual([])
+    expect(entry).not.toContain('/@xterm+xterm@')
+    expect(terminal).toContain('/@xterm+xterm@')
+  })
+})

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است