Ver código fonte

Merge remote-tracking branch 'origin/master' into desktop/native-fatal-recovery

# Conflicts:
#	apps/web/tests/composer-draft-scroll.e2e.ts
07akioni 1 mês atrás
pai
commit
8c28d87de8
94 arquivos alterados com 1008 adições e 384 exclusões
  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. 3 3
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.i18n.yaml
  8. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.md
  9. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.zh.md
  10. 0 29
      .agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md
  11. 0 29
      .agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md
  12. 6 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.i18n.yaml
  13. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md
  14. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.zh.md
  15. 0 92
      apps/web/tests/composer-draft-scroll.e2e.ts
  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/subsystems/client-modules.i18n.yaml
  21. 2 2
      docs/subsystems/client-modules.md
  22. 2 2
      docs/subsystems/client-modules.zh.md
  23. 2 2
      packages/api/gateway/README.i18n.yaml
  24. 5 5
      packages/api/gateway/README.md
  25. 5 5
      packages/api/gateway/README.zh.md
  26. 4 18
      packages/api/gateway/src/client/index.ts
  27. 10 7
      packages/api/gateway/tests/gateway.client.spec.ts
  28. 4 9
      packages/api/remotes/tests/built-lib.e2e.ts
  29. 1 0
      packages/client/AGENTS.md
  30. 2 2
      packages/client/hmr/README.i18n.yaml
  31. 2 2
      packages/client/hmr/README.md
  32. 2 2
      packages/client/hmr/README.zh.md
  33. 5 4
      packages/client/hmr/src/index.ts
  34. 2 2
      packages/client/modules/README.i18n.yaml
  35. 3 3
      packages/client/modules/README.md
  36. 3 3
      packages/client/modules/README.zh.md
  37. 17 7
      packages/client/modules/src/client/manifest.ts
  38. 87 18
      packages/client/modules/src/client/system.ts
  39. 73 9
      packages/client/modules/src/index.ts
  40. 159 0
      packages/client/modules/tests/loader.client.spec.ts
  41. 59 1
      packages/client/modules/tests/node-half.client.spec.ts
  42. 52 7
      packages/client/tsdown.client.ts
  43. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  44. 1 1
      packages/client/ui-conversation/README.md
  45. 1 1
      packages/client/ui-conversation/README.zh.md
  46. 5 9
      packages/client/ui-conversation/src/client/input/editor/runtime.ts
  47. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  48. 2 1
      packages/client/ui-sidebar-documentpreview/README.md
  49. 2 1
      packages/client/ui-sidebar-documentpreview/README.zh.md
  50. 1 0
      packages/client/ui-sidebar-documentpreview/package.json
  51. 18 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx
  52. 14 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  53. 3 2
      packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts
  54. 8 5
      packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx
  55. 0 3
      packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts
  56. 2 2
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  57. 1 1
      packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx
  58. 14 3
      packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts
  59. 17 4
      packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts
  60. 3 2
      packages/client/ui-sidebar-documentpreview/tsdown.config.ts
  61. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  62. 2 1
      packages/client/ui-sidebar-terminal/README.md
  63. 2 1
      packages/client/ui-sidebar-terminal/README.zh.md
  64. 1 0
      packages/client/ui-sidebar-terminal/package.json
  65. 14 0
      packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx
  66. 2 0
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css
  67. 1 1
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx
  68. 2 2
      packages/client/ui-sidebar-terminal/src/client/index.ts
  69. 0 2
      packages/client/ui-sidebar-terminal/src/client/terminal.module.css
  70. 5 3
      packages/client/ui-sidebar-terminal/src/client/terminal.tsx
  71. 13 3
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  72. 22 0
      packages/client/ui-sidebar-terminal/tests/bundle-split.client.spec.ts
  73. 1 1
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  74. 4 2
      packages/experimental/webworker-packer/src/rules.ts
  75. 9 0
      packages/experimental/webworker-packer/tests/image-loadable.spec.ts
  76. 2 2
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  77. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  78. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  79. 2 1
      packages/llm/llm-pi-ai/README.md
  80. 2 1
      packages/llm/llm-pi-ai/README.zh.md
  81. 1 1
      packages/llm/llm-pi-ai/src/adapter.ts
  82. 1 1
      packages/llm/llm-pi-ai/src/login.ts
  83. 67 0
      packages/llm/llm-pi-ai/src/models.ts
  84. 1 1
      packages/llm/llm-pi-ai/src/provider.ts
  85. 1 1
      packages/llm/llm-pi-ai/src/stream.ts
  86. 24 1
      packages/llm/llm-pi-ai/tests/catalog.spec.ts
  87. 2 2
      packages/llm/llm-pi-ai/tests/login.spec.ts
  88. 8 0
      scripts/check-workspace-constraints.spec.ts
  89. 2 0
      scripts/check-workspace-constraints.ts
  90. 4 0
      scripts/client-build-environment.client.spec.ts
  91. 2 0
      scripts/client-build-environment.ts
  92. 28 0
      scripts/client-bundle-purity.spec.ts
  93. 8 0
      scripts/verify-default-product-isolation.spec.ts
  94. 5 2
      scripts/verify-default-product-isolation.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

Diferenças do arquivo suprimidas por serem muito extensas
+ 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 不可用必须明确失败。
 

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.i18n.yaml → .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md
-2026-09-15-composer-paste-line-breaks.md: 8411644c31b8b48dfd16ceb08815062bcb32f99f
-2026-09-15-composer-paste-line-breaks.zh.md: beb2f5ea7a75dd24392f5c28e99189f186a557e4
+#   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 或多协议自定义路由,必须同时扩展该本地函数及其测试。

+ 0 - 29
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md

@@ -1,29 +0,0 @@
-# Agent Note: Composer paste inserts line breaks
-
-Status: implemented
-
-English | [中文](2026-09-15-composer-paste-line-breaks.zh.md)
-
-## Problem
-
-A pasted multi-line draft and a typed one hold the same text in different documents. The composer's paste path inserted clipboard text through `RangeSelection.insertText`, which keeps every newline inside one text node, while typing and the editor's own programmatic writes produce a line-break node per line.
-
-Inside a single text node the browser reports the collapsed caret's geometry as the block's. With a pasted draft scrolled to its end, deleting the trailing line made Lexical's collapsed-caret reveal — `scrollIntoViewIfNeeded`, reached from `$updateDOMSelection` — read a zero-height rect at the top of the block and write that offset into the composer's scrollport. The capped box then showed the head of the draft with the caret off screen, in a draft that still overflowed. The same document also gave Home and vertical caret movement block boundaries instead of line boundaries.
-
-## Decision
-
-[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) inserts through `RangeSelection.insertRawText`, the path Lexical's own paste handler uses; its raw-text generator splits the input into text and line-break nodes. A pasted draft therefore carries the document a typed draft carries, and both project to the same draft text.
-
-## Alternatives considered
-
-**Keep `insertText` and correct the reveal in the composer view.** The composer's own `revealDraftSelection` never ran during the failing gesture: the write came from Lexical's selection reconciliation, which owns the caret reveal for a collapsed selection. Guarding that function would not have reached it, and suppressing Lexical's reveal would remove the caret tracking ordinary typing depends on.
-
-**Rewrite newlines inside the pasted text.** Normalizing the clipboard text before insertion leaves the document unchanged — the defect is the node structure the insertion produces, not the characters it receives.
-
-## Consequences
-
-Pasted newlines become line-break nodes instead of characters inside a text node. The clipboard projection, and with it the submitted prompt, the draft mirror, and trigger spans, is unchanged: `$composerLayout` projects a line-break node and a paragraph gap alike as one `\n`. Tab characters reach `TabNode` through the same raw-text path. The composer no longer relies on the browser reporting per-line geometry for a draft stored in one text node.
-
-## Testing
-
-[The composer scroll scenario](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) pins the behavior in a real browser: a pasted draft with a blank tail, scrolled to its end, holds `scrollTop === scrollMax` through an end-of-draft Backspace and through a selection delete of the trailing lines. Reverting `paste()` to `insertText` fails that case on `scrollTop === scrollMax` while the draft still overflows; the scenario's typed-draft cases and its committed geometry golden pass with either insertion path.

+ 0 - 29
.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md

@@ -1,29 +0,0 @@
-# Agent Note: Composer 粘贴插入换行节点
-
-Status: implemented
-
-[English](2026-09-15-composer-paste-line-breaks.md) | 中文
-
-## 问题
-
-粘贴的多行草稿与逐行打字的草稿文本相同,但文档不同。Composer 的粘贴路径经 `RangeSelection.insertText` 插入剪贴板文本,会把每个换行都留在同一个文本节点内;而打字和编辑器自身的程序化写入会为每行产生一个换行节点。
-
-在单个文本节点内,浏览器把折叠光标的几何信息报告为整个块的几何信息。当粘贴草稿滚动到末尾时,删掉末尾那行会让 Lexical 自己的折叠光标 reveal(`scrollIntoViewIfNeeded`,由 `$updateDOMSelection` 到达)读到一个位于块顶部的零高度矩形,并把该偏移写进 composer 的滚动容器。于是这个带高度上限的框显示出草稿开头、光标落在屏幕外,而草稿本身仍然溢出。同一份文档还让 Home 键与纵向光标移动拿到块级边界,而不是行级边界。
-
-## 决策
-
-[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) 改经 `RangeSelection.insertRawText` 插入,这也是 Lexical 自身粘贴处理器所用的路径;它的 raw-text 生成器把输入拆成文本节点与换行节点。因此粘贴草稿与打字草稿携带相同的文档,两者投影出相同的草稿文本。
-
-## 考虑过的替代方案
-
-**保留 `insertText`,在 composer 视图里修正 reveal。** 失败手势中 composer 自己的 `revealDraftSelection` 从未运行:那次写入来自 Lexical 的选区对账,折叠选区的光标 reveal 归它所有。给该函数加守卫无法触及它,而屏蔽 Lexical 的 reveal 又会去掉普通打字所依赖的光标跟随。
-
-**在粘贴文本内重写换行。** 插入前规范化剪贴板文本不会改变文档——缺陷在于插入产生的节点结构,而不在于它接收的字符。
-
-## 影响
-
-粘贴的换行成为换行节点,而不再是文本节点内的字符。剪贴板投影、并因此提交的 prompt、草稿镜像和触发词跨度都不变:`$composerLayout` 把换行节点与段落间隙同样投影为一个 `\n`。制表符经同一条 raw-text 路径落到 `TabNode`。composer 不再依赖浏览器为「整篇存于一个文本节点」的草稿报告逐行几何信息。
-
-## 测试
-
-[Composer 滚动场景](../../../../apps/web/tests/composer-draft-scroll.e2e.ts)在真实浏览器里固定该行为:尾部为空行的粘贴草稿滚动到末尾后,在草稿末尾按退格、以及选中末尾若干行后删除,都保持 `scrollTop === scrollMax`。把 `paste()` 退回 `insertText` 会让该用例在草稿仍然溢出时于 `scrollTop === scrollMax` 上失败;该场景中的打字草稿用例与已提交的几何 golden 在两种插入路径下都通过。

+ 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 输入拒绝。

+ 0 - 92
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -73,50 +73,6 @@ async function typeDraft(page: Page, rows: readonly string[]): Promise<void> {
   }
 }
 
-/**
- * Replace the draft with one pasted block through the composer's own paste
- * path (the keymap's ClipboardEvent route).
- * @param page - the page under test.
- * @param rows - draft lines, joined with '\n' — one paste, not one keystroke each.
- */
-async function pasteDraft(page: Page, rows: readonly string[]): Promise<void> {
-  await typeDraft(page, [])
-  await surface(page).evaluate((el, text) => {
-    const data = new DataTransfer()
-    data.setData('text/plain', text)
-    el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
-  }, rows.join('\n'))
-}
-
-/** The composer's scrollport alone, for measurements taken after an edit removed the marked lines. */
-interface ScrollportMetrics {
-  /** True when the draft is taller than the capped box. */
-  overflows: boolean
-  /** The composer's one scroll offset. */
-  scrollTop: number
-  /** Furthest that offset can go. */
-  scrollMax: number
-}
-
-/**
- * Measure the composer's scrollport without addressing the draft's marked lines.
- * @param page - the page under test.
- * @returns the offset, its maximum, and whether the draft overflows.
- */
-function measureScrollport(page: Page): Promise<ScrollportMetrics> {
-  return page.evaluate(() => {
-    const input = document.querySelector<HTMLElement>('[data-composer-input][contenteditable="true"]')
-    if (input === null) throw new Error('no live composer surface in the DOM')
-    const scroll = input.closest<HTMLElement>('[data-input-scroll]')
-    if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport')
-    return {
-      overflows: scroll.scrollHeight > scroll.clientHeight,
-      scrollTop: scroll.scrollTop,
-      scrollMax: scroll.scrollHeight - scroll.clientHeight,
-    }
-  })
-}
-
 /** The composer's scroll surface as the browser lays it out. */
 interface ComposerMetrics {
   /** True when the draft is taller than the capped box — the situation under test. */
@@ -332,54 +288,6 @@ describe('web e2e: composer draft scrolling', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('deleting a pasted draft\'s trailing lines keeps the box at the end', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste-delete'))
-    // A pasted block is where a draft's structure and a typed one diverge: a
-    // paste that keeps its newlines inside one text node leaves the browser
-    // revealing the caret from a rect that spans the whole block, which jumps
-    // the capped box back to the draft's head on the next edit at its end.
-    await pasteDraft(page, [...DRAFT_ROWS, '', '', ''])
-    await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
-    await surface(page).hover()
-    await page.mouse.wheel(0, 4000)
-    await expect.poll(async () => {
-      const m = await measureScrollport(page)
-      return m.scrollTop === m.scrollMax
-    }, { timeout: 10_000 }).toBe(true)
-    // Chromium updates the DOM selection before delivering selectionchange
-    // to Lexical. Deliver it before deletion so the editor sees the end caret.
-    const input = surface(page)
-    await input.click()
-    await page.keyboard.press('ControlOrMeta+KeyA')
-    await page.keyboard.press('ArrowRight')
-    await expect.poll(
-      async () => page.evaluate(() => window.getSelection()?.isCollapsed ?? false),
-      { timeout: 10_000 },
-    ).toBe(true)
-    await input.evaluate(el => el.ownerDocument.dispatchEvent(new Event('selectionchange')))
-    await page.keyboard.press('Backspace')
-    await expect.poll(async () => (await measureScrollport(page)).overflows, { timeout: 10_000 }).toBe(true)
-    await expect.poll(async () => {
-      const m = await measureScrollport(page)
-      return m.scrollTop === m.scrollMax
-    }, { timeout: 10_000 }).toBe(true)
-    // The box is at its furthest offset rather than back at the draft's head.
-    const afterBackspace = await measureScrollport(page)
-    expect(afterBackspace.scrollMax).toBeGreaterThan(0)
-    expect(afterBackspace.scrollTop).toBe(afterBackspace.scrollMax)
-    // The same failure through the selection gesture: select the trailing
-    // lines and delete them at once.
-    await page.keyboard.press('Shift+ArrowUp')
-    await page.keyboard.press('Shift+ArrowUp')
-    await input.evaluate(el => el.ownerDocument.dispatchEvent(new Event('selectionchange')))
-    await page.keyboard.press('Delete')
-    await expect.poll(async () => {
-      const m = await measureScrollport(page)
-      return m.overflows && m.scrollTop === m.scrollMax
-    }, { timeout: 10_000 }).toBe(true)
-    expect(tripwire.pageErrors).toEqual([])
-  }, 60_000)
-
   it('matches the committed composer scroll geometry golden', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
     // Restore the pristine draft (the edit cases appended to it) and return to

+ 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/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
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 } },

+ 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 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: afafb1d9fd579a0d5fdd6ed004ed081ff8c38483
-README.zh.md: ad688a674e70d2a447cdedeaec9a2621a644d47d
+README.md: 3c2697487ffe5b79bc8209eb9be953cc70977d79
+README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -52,7 +52,7 @@ The shell reads the persisted View preference before rendering when a Session fi
 
 Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
 
-The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, pasted text enters through the raw-text path so its newlines become the same line-break nodes a typed draft carries, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -52,7 +52,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,粘贴文本经 raw-text 路径进入,其换行成为打字草稿所携带的同种换行节点,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 5 - 9
packages/client/ui-conversation/src/client/input/editor/runtime.ts

@@ -162,13 +162,9 @@ export class DraftEditorRuntime {
 
   /**
    * Insert pasted plain text over the current editor selection
-   * (placeholder-sanitized). The raw-text path is what splits pasted newlines
-   * into line-break nodes — the document a typed draft already has; a text node
-   * holding them instead leaves the browser reporting the whole block as the
-   * collapsed caret's geometry, which removes the scrollport's offset on the
-   * next edit. The paste event's own default is suppressed by the caller;
-   * PASTE_TAG makes the paste its own history boundary, so one undo never
-   * removes both the paste and typing inside the merge window.
+   * (placeholder-sanitized). The paste event's own default is suppressed by
+   * the caller; PASTE_TAG makes the paste its own history boundary, so one
+   * undo never removes both the paste and typing inside the merge window.
    * @param text - pasted plain text.
    */
   paste(text: string): void {
@@ -177,14 +173,14 @@ export class DraftEditorRuntime {
     this.applyEdit(() => {
       const selection = $getSelection()
       if ($isRangeSelection(selection)) {
-        selection.insertRawText(clean)
+        selection.insertText(clean)
         return
       }
       // No selection yet (never-focused surface): land at the document end,
       // growing the first paragraph when the tree is empty.
       const root = $getRoot()
       if (root.getChildrenSize() === 0) root.append($createParagraphNode())
-      root.selectEnd().insertRawText(clean)
+      root.selectEnd().insertText(clean)
     }, PASTE_TAG)
   }
 

+ 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@')
+  })
+})

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

@@ -8,7 +8,7 @@ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
-import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx'
+import { TerminalBody, type TerminalBodyProps } from '../src/client/terminal.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { en, zh } from '../src/client/locales.ts'
 

+ 4 - 2
packages/experimental/webworker-packer/src/rules.ts

@@ -42,8 +42,8 @@ export const EXCLUDE_WORKSPACE: readonly string[] = [
 /**
  * Image paths that belong to the PAGE, not to the worker's loader.
  *
- * A package's `lib/client.js` is its browser bundle behind the `./client`
- * export: the page's own module system evaluates it with its own wrapper,
+ * A package's `lib/client.js` entry and `lib/client.*.js` sibling chunks are
+ * browser bundles behind the `./client` export: the page's own module system evaluates them with its own wrapper,
  * which has no ambient-store parameter. Transforming those bodies would
  * inject calls the page cannot resolve, so they ship untransformed — their
  * only change is the trailing debugger-name line every JavaScript entry
@@ -52,7 +52,9 @@ export const EXCLUDE_WORKSPACE: readonly string[] = [
  */
 export const PAGE_ASSETS: readonly string[] = [
   'node_modules/*/lib/client.js',
+  'node_modules/*/lib/client.*.js',
   'node_modules/@*/*/lib/client.js',
+  'node_modules/@*/*/lib/client.*.js',
 ]
 
 /**

+ 9 - 0
packages/experimental/webworker-packer/tests/image-loadable.spec.ts

@@ -20,6 +20,7 @@ import { existsSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
+import picomatch from 'picomatch'
 import { FiberState } from '@deepseek-ai/cordis'
 import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts'
 import {
@@ -30,6 +31,7 @@ import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/sr
 import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
 import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts'
 import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts'
+import { PAGE_ASSETS } from '../src/rules.ts'
 
 const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
 
@@ -42,6 +44,13 @@ const WEB_SERVER = '@deepseek-ai/dsh-host-webserver'
 
 const workspaces = indexWorkspacePackages(repoRoot)
 
+it.each([
+  'node_modules/plain/lib/client.pdf.js',
+  'node_modules/@scope/plugin/lib/client.terminal.js',
+])('keeps page-owned Client chunk %s outside the Worker reachability sweep', (path) => {
+  expect(picomatch([...PAGE_ASSETS], { dot: true })(path)).toBe(true)
+})
+
 describe('preview example overlays', () => {
   it('packs source-looking paths and dot directories into a separate overlay', () => {
     const fixture = previewFixtures(repoRoot)[0]

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

@@ -2343,7 +2343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-sidebar-documentpreview TextPreview',
       'client-ui-sidebar-files FilesBody',
       'client-ui-sidebar-right GuideBody',
-      'client-ui-sidebar-terminal TerminalBody',
+      'client-ui-sidebar-terminal LazyTerminalBody',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n      { name: \'sidebar.right.pane.tab\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
@@ -2440,7 +2440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-sidebar-documentpreview HtmlBody',
       'client-ui-sidebar-documentpreview ImageBody',
       'client-ui-sidebar-documentpreview MarkdownBody',
-      'client-ui-sidebar-documentpreview PdfBody',
+      'client-ui-sidebar-documentpreview LazyPdfBody',
       'client-ui-sidebar-documentpreview TextBody',
     ],
     replaceRisk: 'none',

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

@@ -638,7 +638,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'rebuilt(id: string): string | undefined',
-        description: 'Re-hash one bundle (the HMR watch\'s registration hook — the only entry point through which bundle content changes reach the graph).',
+        description: 'Publish one completed bundle generation (the HMR watch\'s registration hook — the only entry point through which build changes reach the graph).',
         parameters: [{ name: 'id', description: 'entry id (package name).' }],
         returns: 'the new rev, or undefined for an unknown id.',
       },

+ 2 - 2
packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md
-README.md: a49281b5ddc63c1fe4ce628857ff6f267dc498cd
-README.zh.md: 78aed7db98e217691351f49f3f8ee41e74177e17
+README.md: ceb6c75ad22534db7af62e915e1aee1f11d55390
+README.zh.md: 3c6697fea5c5c185af29da8b7156f74ca322d339

+ 2 - 1
packages/llm/llm-pi-ai/README.md

@@ -130,7 +130,7 @@ This section explains the design behind the adapter; the observable behavior is
 
 ### Design philosophy
 
-The adapter is built on immutable snapshots and per-operation resolution. Each operation captures a whole snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — before its first `await`, and a configuration change builds a new collection rather than mutating the one in use, so a request that started under one configuration never finishes under another. A route's own credential reference resolves through the harness seam and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — that is what keeps the fail-loud reference semantics. Everything that override does not cover reaches pi-ai through the collection's own auth: the credential store holds the records a login wrote and a refresh rotates (addressed as `llm-pi-ai/<provider id>`), and the auth context answers the ambient questions a provider asks while resolving. Both are stable across snapshots, so a configuration change rebuilds the collection without forgetting who is signed in.
+The adapter is built on immutable snapshots and per-operation resolution. Each operation captures a whole snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — before its first `await`, and a configuration change builds a new collection rather than mutating the one in use, so a request that started under one configuration never finishes under another. A route's own credential reference resolves through the harness seam and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — that is what keeps the fail-loud reference semantics. Everything that override does not cover reaches pi-ai through the collection's own auth: the credential store holds the records a login wrote and a refresh rotates (addressed as `llm-pi-ai/<provider id>`), and the auth context answers the ambient questions a provider asks while resolving. Both are stable across snapshots, so a configuration change rebuilds the collection without forgetting who is signed in. Runtime imports use pi-ai's provider, API, and utility entry points; `src/models.ts` supplies the small model-helper subset this adapter needs without evaluating pi-ai's aggregate entry point.
 
 ### Source map
 
@@ -141,6 +141,7 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o
 | [`src/login.ts`](src/login.ts) | Authorization flows for the installed providers that ship a login |
 | [`src/config.ts`](src/config.ts) | Profile schema, resolution, and serviceability checks |
 | [`src/catalog.ts`](src/catalog.ts) | Installed-catalog integration and drift gates |
+| [`src/models.ts`](src/models.ts) | Model collections, static providers, and reasoning levels over narrow pi-ai entry points |
 | [`src/provider.ts`](src/provider.ts) | The supported-protocol table and provider construction |
 | [`src/context.ts`](src/context.ts) | Harness-to-pi-ai context conversion, image handling, replay restore |
 | [`src/stream.ts`](src/stream.ts) | pi-ai event conversion into harness `StreamChunk` values |

+ 2 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -130,7 +130,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或
 
 ### 设计理念
 
-适配器建立在不可变快照与按操作解析之上。每个操作都会在第一次 `await` 前捕获整个快照——profile 加一个持有每条路由所构建 `Provider` 的 `createModels()` 集合——配置变更会构建新集合而非修改使用中的集合,因此在一个配置下开始的请求绝不会在另一个配置下结束。路由自己的凭据引用经 harness seam 解析,并以请求 `apiKey` 选项传入,pi-ai 将其视为优先级最高的 auth 覆盖——这正是明确失败引用语义的所在。该覆盖未覆盖的一切都经集合自身的 auth 到达 pi-ai:凭据存储持有登录写入、刷新轮换的记录(以 `llm-pi-ai/<provider id>` 寻址),auth context 回答提供方解析时提出的 ambient 问题。两者跨快照保持稳定,因此配置变更重建集合时不会忘记谁已登录。
+适配器建立在不可变快照与按操作解析之上。每个操作都会在第一次 `await` 前捕获整个快照——profile 加一个持有每条路由所构建 `Provider` 的 `createModels()` 集合——配置变更会构建新集合而非修改使用中的集合,因此在一个配置下开始的请求绝不会在另一个配置下结束。路由自己的凭据引用经 harness seam 解析,并以请求 `apiKey` 选项传入,pi-ai 将其视为优先级最高的 auth 覆盖——这正是明确失败引用语义的所在。该覆盖未覆盖的一切都经集合自身的 auth 到达 pi-ai:凭据存储持有登录写入、刷新轮换的记录(以 `llm-pi-ai/<provider id>` 寻址),auth context 回答提供方解析时提出的 ambient 问题。两者跨快照保持稳定,因此配置变更重建集合时不会忘记谁已登录。运行时 import 使用 pi-ai 的 provider、API 与 utility 入口;`src/models.ts` 提供本适配器所需的少量 model helper,而不会求值 pi-ai 聚合入口。
 
 ### 源码地图
 
@@ -141,6 +141,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或
 | [`src/login.ts`](src/login.ts) | 面向提供登录的已安装提供方的授权流程 |
 | [`src/config.ts`](src/config.ts) | Profile schema、解析与可服务性校验 |
 | [`src/catalog.ts`](src/catalog.ts) | 已安装目录集成与漂移门禁 |
+| [`src/models.ts`](src/models.ts) | 基于 pi-ai 窄入口的 model collection、静态 provider 与 reasoning level |
 | [`src/provider.ts`](src/provider.ts) | 受支持协议表与提供方构建 |
 | [`src/context.ts`](src/context.ts) | Harness 到 pi-ai 的上下文转换、图片处理、回放恢复 |
 | [`src/stream.ts`](src/stream.ts) | 把 pi-ai 事件转换为 harness `StreamChunk` 值 |

+ 1 - 1
packages/llm/llm-pi-ai/src/adapter.ts

@@ -26,7 +26,6 @@
  * @module dsh-llm-pi-ai/adapter
  */
 
-import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
 import type {
   Api,
   AuthContext,
@@ -60,6 +59,7 @@ import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attac
 import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import type { ResolvedPiAiProviderProfile } from './config.ts'
 import { toPiContext } from './context.ts'
+import { createModels, getSupportedThinkingLevels } from './models.ts'
 import { toStreamChunks } from './stream.ts'
 
 /** One resolution's frozen view: the profiles and the collection built from them. */

+ 1 - 1
packages/llm/llm-pi-ai/src/login.ts

@@ -7,7 +7,6 @@
  * @module dsh-llm-pi-ai/login
  */
 
-import { createModels } from '@earendil-works/pi-ai'
 import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai'
 import type { Context } from '@deepseek-ai/cordis'
 import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization'
@@ -15,6 +14,7 @@ import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials'
 import { catalogProvider, catalogProviderIds } from './catalog.ts'
 import { recordKeyFor } from './auth.ts'
 import type { PiAiAuthInjection } from './adapter.ts'
+import { createModels } from './models.ts'
 
 /**
  * The login methods one catalog provider offers.

+ 67 - 0
packages/llm/llm-pi-ai/src/models.ts

@@ -0,0 +1,67 @@
+/** pi-ai model helpers assembled from public narrow entry points. */
+
+import { builtinModels } from '@earendil-works/pi-ai/providers/all'
+import type {
+  Api,
+  CreateModelsOptions,
+  Model,
+  ModelThinkingLevel,
+  MutableModels,
+  Provider,
+  ProviderAuth,
+  ProviderStreams,
+} from '@earendil-works/pi-ai'
+import { THINKING_LEVELS } from './catalog.ts'
+
+/** Input accepted by the static, single-protocol providers this package builds. */
+interface StaticProviderOptions {
+  id: string
+  name: string
+  baseUrl?: string
+  auth: ProviderAuth
+  models: readonly Model<Api>[]
+  api: ProviderStreams
+}
+
+/**
+ * Create an empty pi-ai collection without importing its aggregate entry point.
+ * @param options - credential storage and ambient authentication integrations.
+ * @returns a mutable collection with no registered providers.
+ */
+export function createModels(options?: CreateModelsOptions): MutableModels {
+  const models = builtinModels(options)
+  models.clearProviders()
+  return models
+}
+
+/**
+ * Create the static, single-protocol provider used by configured custom routes.
+ * @param input - provider identity, models, authentication, and protocol implementation.
+ * @returns a provider that delegates each operation to the supplied protocol.
+ */
+export function createProvider(input: StaticProviderOptions): Provider {
+  return {
+    id: input.id,
+    name: input.name,
+    ...input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl },
+    auth: input.auth,
+    getModels: () => input.models,
+    stream: (model, context, options) => input.api.stream(model, context, options),
+    streamSimple: (model, context, options) => input.api.streamSimple(model, context, options),
+  }
+}
+
+/**
+ * Resolve selectable reasoning levels from pi-ai's public model metadata.
+ * @param model - model descriptor carrying reasoning support and wire mappings.
+ * @returns supported levels in pi-ai's escalation order.
+ */
+export function getSupportedThinkingLevels(model: Model<Api>): ModelThinkingLevel[] {
+  if (!model.reasoning) return ['off']
+  return THINKING_LEVELS.filter((level) => {
+    const mapped = model.thinkingLevelMap?.[level]
+    if (mapped === null) return false
+    if (level === 'xhigh' || level === 'max') return mapped !== undefined
+    return true
+  })
+}

+ 1 - 1
packages/llm/llm-pi-ai/src/provider.ts

@@ -19,12 +19,12 @@
  * @module dsh-llm-pi-ai/provider
  */
 
-import { createProvider } from '@earendil-works/pi-ai'
 import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai'
 import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
 import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
 import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
 import { catalogProvider, PiAiCatalogError } from './catalog.ts'
+import { createProvider } from './models.ts'
 
 /**
  * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded

+ 1 - 1
packages/llm/llm-pi-ai/src/stream.ts

@@ -11,7 +11,7 @@
 import { brandString } from '@deepseek-ai/dsh-brand'
 import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
 import type { FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm'
-import { isContextOverflow } from '@earendil-works/pi-ai'
+import { isContextOverflow } from '@earendil-works/pi-ai/utils/overflow'
 import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai'
 import { toPiReplayState } from './replay.ts'
 

+ 24 - 1
packages/llm/llm-pi-ai/tests/catalog.spec.ts

@@ -9,9 +9,10 @@ import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
 import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
 import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
 import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
-import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
+import { AssistantMessageEventStream } from '@earendil-works/pi-ai/utils/event-stream'
 import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
 import { resolveProfiles } from '../src/config.ts'
+import { createModels, createProvider, getSupportedThinkingLevels } from '../src/models.ts'
 import { buildProvider, supportedProtocols } from '../src/provider.ts'
 import { assemble } from './assemble.ts'
 import { memoryAuth } from './auth-double.ts'
@@ -355,6 +356,28 @@ describe('hand-declared providers', () => {
     expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
   })
 
+  it('delegates both stream methods from a static provider', () => {
+    const [model] = getBuiltinModels('deepseek')
+    if (model === undefined) throw new Error('the installed catalog ships no deepseek model')
+    const direct = new AssistantMessageEventStream()
+    const simple = new AssistantMessageEventStream()
+    const stream = vi.fn(() => direct)
+    const streamSimple = vi.fn(() => simple)
+    const provider = createProvider({
+      id: 'local',
+      name: 'Local',
+      models: [model],
+      auth: { apiKey: { name: 'Local', resolve: () => Promise.resolve({ auth: {}, source: 'Local' }) } },
+      api: { stream, streamSimple },
+    })
+    const context = { messages: [] }
+
+    expect(provider.stream(model, context)).toBe(direct)
+    expect(provider.streamSimple(model, context)).toBe(simple)
+    expect(stream).toHaveBeenCalledOnce()
+    expect(streamSimple).toHaveBeenCalledOnce()
+  })
+
   it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => {
     const server = await mockServer([{ events: textEvents }])
     // Naming no credential is the deliberately unauthenticated posture — a

+ 2 - 2
packages/llm/llm-pi-ai/tests/login.spec.ts

@@ -14,8 +14,8 @@ const login = vi.hoisted(() => vi.fn())
 // The whole of what this module does with pi-ai is run one provider's login
 // against a collection built with the harness store, so the collection is the
 // boundary worth observing; a real login would open a browser.
-vi.mock('@earendil-works/pi-ai', async importOriginal => ({
-  ...await importOriginal<typeof import('@earendil-works/pi-ai')>(),
+vi.mock('../src/models.ts', async importOriginal => ({
+  ...await importOriginal<typeof import('../src/models.ts')>(),
   createModels: () => ({ setProvider: () => {}, login }),
 }))
 

+ 8 - 0
scripts/check-workspace-constraints.spec.ts

@@ -158,6 +158,14 @@ describe('package payload constraints', () => {
       'lib/types/**/*.d.ts',
     ])
   })
+
+  it.each([
+    'packages/client/ui-sidebar-documentpreview',
+    'packages/client/ui-sidebar-terminal',
+  ])('accepts package-local Client chunks from %s', (dir) => {
+    const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+    expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
+  })
 })
 
 it('publishes CLI runtime declarations and rejects a payload that omits them', () => {

+ 2 - 0
scripts/check-workspace-constraints.ts

@@ -156,6 +156,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // unpublished, as everywhere else in the repository.
   '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
+  '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': ['lib/client.*.js'],
+  '@deepseek-ai/dsh-client-ui-sidebar-terminal': ['lib/client.*.js'],
   '@deepseek-ai/dsh-client-web': ['lib/**/*.css', 'lib/apply-injections.js'],
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
   // The CPython side ships as source .py files, published as-is rather than built.

+ 4 - 0
scripts/client-build-environment.client.spec.ts

@@ -275,6 +275,10 @@ describe('client build environment', () => {
 
     write(join(official, 'apps/web/dist/index.html'), '<main>changed</main>')
     expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/)
+
+    const chunked = buildFixture(officialEnvironment)
+    write(join(chunked, 'packages/client/example/lib/client.pdf.js'), 'module.exports = {}\n')
+    expect(() => { readClientBuildRecord(chunked) }).toThrow(/artifacts differ/)
   })
 
   it('keeps public client values out of workflow-wide environments', () => {

+ 2 - 0
scripts/client-build-environment.ts

@@ -36,6 +36,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',
 ] as const
 
 /** Public values embedded in one set of client artifacts. */

+ 28 - 0
scripts/client-bundle-purity.spec.ts

@@ -56,6 +56,34 @@ describe('client bundle build faces', () => {
   })
 })
 
+describe('client bundle dynamic imports', () => {
+  it('compiles import() to the module loader asynchronous operation', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-client-dynamic-import-'))
+    onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
+    const entry = join(root, 'lib/types/client/index.js')
+    mkdirSync(dirname(entry), { recursive: true })
+    writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
+    writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
+    writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
+    const config = clientConfigs()[0]
+    if (config === undefined) throw new Error('client config missing')
+
+    let builds: TsdownBundle[] = []
+    try {
+      builds = await build({
+        ...config, cwd: root, config: false, tsconfig: false,
+        write: false, clean: false, exports: false, report: false, logLevel: 'silent',
+      })
+      const chunks = builds.flatMap(bundle => bundle.chunks).filter(chunk => chunk.type === 'chunk')
+      const output = chunks.find(chunk => chunk.fileName === 'client.js')?.code
+      expect(output).toContain('require.async("./client.terminal.js")')
+      expect(output).not.toContain('Promise.resolve().then(() => require("./client.terminal.js"))')
+    } finally {
+      for (const bundle of builds) await bundle[Symbol.asyncDispose]()
+    }
+  })
+})
+
 function clientSourceMapPath(packagePath: string): string {
   return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
 }

+ 8 - 0
scripts/verify-default-product-isolation.spec.ts

@@ -61,6 +61,14 @@ describe('default product isolation', () => {
     expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5, configCount: 2 })
   })
 
+  it('ignores dependency trees and directories whose names end in a source extension', () => {
+    const root = fixture()
+    mkdirSync(join(root, 'python/sdk-runtime/src/vendor.js'), { recursive: true })
+    write(root, 'python/sdk-runtime/src/node_modules/vendor/index.js', `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures).toEqual([])
+  })
+
   it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])(
     'rejects transitive experimental %s',
     (section) => {

+ 5 - 2
scripts/verify-default-product-isolation.ts

@@ -275,9 +275,12 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
     }
     if (display(pkg.directory) === 'apps/web') continue
     const files = globSync('src/**/*.{ts,tsx,mts,cts,js,mjs,cjs}', { cwd: pkg.directory,
-      exclude: ['**/*.spec.*', '**/*.test.*', '**/*.d.ts', '**/tests/**', '**/__tests__/**'] })
+      exclude: ['**/*.spec.*', '**/*.test.*', '**/*.d.ts', '**/tests/**', '**/__tests__/**', '**/node_modules/**'] })
     if (display(pkg.directory) === 'apps/cli' && files.length === 0) failures.push('apps/cli: no default runtime sources')
-    for (const path of files) scanSource(resolve(pkg.directory, path))
+    for (const path of files) {
+      const sourcePath = resolve(pkg.directory, path)
+      if (statSync(sourcePath).isFile()) scanSource(sourcePath)
+    }
   }
   return { failures: [...new Set(failures)], packageCount: visited.size,
     sourceCount: sources.size, configCount: configs.size, webPluginCount }

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff