Ver código fonte

Merge pull request #4192 from deepseek-harness/worktree-bootfast2

perf(web): bootstrap client fast
imccyu 3 dias atrás
pai
commit
0d1f50007f
100 arquivos alterados com 826 adições e 430 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  8. 6 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  9. 6 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  10. 2 2
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
  11. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
  12. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
  13. 1 1
      apps/web/tests/settings-chrome.e2e.ts
  14. 1 0
      benchmarks/package.json
  15. 2 2
      docs/config-catalog.i18n.yaml
  16. 1 0
      docs/config-catalog.md
  17. 1 0
      docs/config-catalog.zh.md
  18. 2 2
      docs/module-graph.i18n.yaml
  19. 2 0
      docs/module-graph.md
  20. 2 0
      docs/module-graph.zh.md
  21. 2 2
      docs/subsystems/client-modules.i18n.yaml
  22. 11 10
      docs/subsystems/client-modules.md
  23. 11 10
      docs/subsystems/client-modules.zh.md
  24. 2 2
      docs/subsystems/typert.i18n.yaml
  25. 6 5
      docs/subsystems/typert.md
  26. 6 5
      docs/subsystems/typert.zh.md
  27. 1 1
      packages/api/gateway/src/client/index.ts
  28. 1 1
      packages/api/gateway/src/index.ts
  29. 3 3
      packages/api/gateway/tests/gateway-stream.host.spec.ts
  30. 12 12
      packages/api/gateway/tests/gateway.client.spec.ts
  31. 1 1
      packages/api/gateway/tests/gateway.host.spec.ts
  32. 1 0
      packages/api/terminal-controller/package.json
  33. 5 10
      packages/api/terminal-controller/src/terminal.ts
  34. 3 0
      packages/api/terminal-controller/tsconfig.host.json
  35. 1 0
      packages/attachment/attachment-local/package.json
  36. 4 1
      packages/attachment/attachment-local/src/image.ts
  37. 10 3
      packages/attachment/attachment-local/src/normalization.ts
  38. 3 1
      packages/attachment/attachment-local/src/request-image.ts
  39. 7 0
      packages/attachment/attachment-local/src/sharp.ts
  40. 40 0
      packages/attachment/attachment-local/tests/lazy-sharp-failure.spec.ts
  41. 2 1
      packages/attachment/attachment-local/tsconfig.json
  42. 2 2
      packages/bundle/web-app/README.i18n.yaml
  43. 1 1
      packages/bundle/web-app/README.md
  44. 1 1
      packages/bundle/web-app/README.zh.md
  45. 3 2
      packages/bundle/web-app/cordis.patch.yml
  46. 2 2
      packages/client/modules/README.i18n.yaml
  47. 3 2
      packages/client/modules/README.md
  48. 3 2
      packages/client/modules/README.zh.md
  49. 5 5
      packages/client/modules/src/client/manifest.ts
  50. 133 78
      packages/client/modules/src/index.ts
  51. 82 3
      packages/client/modules/tests/node-half.client.spec.ts
  52. 2 2
      packages/client/ui-theme/README.i18n.yaml
  53. 1 1
      packages/client/ui-theme/README.md
  54. 1 1
      packages/client/ui-theme/README.zh.md
  55. 27 12
      packages/client/ui-theme/src/boot-theme.ts
  56. 3 3
      packages/client/ui-theme/src/index.ts
  57. 2 0
      packages/client/ui-theme/src/styles/design-platform.css
  58. 28 16
      packages/client/ui-theme/tests/boot-theme.client.spec.ts
  59. 19 12
      packages/client/ui-theme/tests/host.client.spec.ts
  60. 1 3
      packages/client/web/src/boot-page.module.css
  61. 1 5
      packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts
  62. 16 8
      packages/extensions/tool-cordis/src/api-catalog.ts
  63. 1 0
      packages/sandbox/sandbox-windows-acl/package.json
  64. 22 11
      packages/sandbox/sandbox-windows-acl/src/ffi.ts
  65. 2 2
      packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts
  66. 3 0
      packages/sandbox/sandbox-windows-acl/tsconfig.json
  67. 1 0
      packages/subprocess/subprocess-local/package.json
  68. 6 3
      packages/subprocess/subprocess-local/src/index.ts
  69. 4 1
      packages/subprocess/subprocess-local/src/linux-execve.ts
  70. 16 7
      packages/subprocess/subprocess-local/src/windows-inspector.ts
  71. 10 4
      packages/subprocess/subprocess-local/tests/linux-execve.spec.ts
  72. 21 8
      packages/subprocess/subprocess-local/tests/local.spec.ts
  73. 3 0
      packages/subprocess/subprocess-local/tsconfig.json
  74. 1 0
      packages/subprocess/win32-process/package.json
  75. 64 49
      packages/subprocess/win32-process/src/ffi.ts
  76. 10 0
      packages/subprocess/win32-process/src/koffi.ts
  77. 5 4
      packages/subprocess/win32-process/src/process.ts
  78. 3 3
      packages/subprocess/win32-process/tests/ffi.spec.ts
  79. 7 7
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  80. 10 4
      packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts
  81. 5 5
      packages/subprocess/win32-process/tests/process-failure-paths.spec.ts
  82. 4 4
      packages/subprocess/win32-process/tests/process.spec.ts
  83. 3 0
      packages/subprocess/win32-process/tsconfig.json
  84. 1 0
      packages/terminal/terminal-bash/package.json
  85. 17 7
      packages/terminal/terminal-bash/src/index.ts
  86. 3 3
      packages/terminal/terminal-bash/src/session.ts
  87. 35 0
      packages/terminal/terminal-bash/tests/index.spec.ts
  88. 3 0
      packages/terminal/terminal-bash/tsconfig.json
  89. 2 2
      packages/typert/generator/README.i18n.yaml
  90. 2 2
      packages/typert/generator/README.md
  91. 2 2
      packages/typert/generator/README.zh.md
  92. 7 6
      packages/typert/generator/src/emitter.ts
  93. 5 4
      packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap
  94. 15 15
      packages/typert/generator/tests/remote-model.spec.ts
  95. 2 2
      packages/typert/generator/tests/schema-emitter.spec.ts
  96. 7 6
      packages/typert/generator/tests/type-model.spec.ts
  97. 2 2
      packages/typert/loader/README.i18n.yaml
  98. 3 3
      packages/typert/loader/README.md
  99. 3 3
      packages/typert/loader/README.zh.md
  100. 4 7
      packages/typert/loader/src/index.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-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-27-compiler-independent-typert-model.md
-2026-07-27-compiler-independent-typert-model.md: c9e7ec0e471c9deabcffda69346077a2a10ac42a
-2026-07-27-compiler-independent-typert-model.zh.md: 9bd419f39c9d8a490dfb0baf12a9852fdbb817a8
+2026-07-27-compiler-independent-typert-model.md: 5083c55176b2f4d342d14d0c1cf2a7f0c2c14b3c
+2026-07-27-compiler-independent-typert-model.zh.md: 70d6954fae0fcb48e76137231168d5f2417668bf

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md

@@ -20,7 +20,7 @@ Each face independently owns a PackageModel and TypeGraph. Direct project refere
 
 PackageModel recognizes Cordis services, events, `@typert object` reference objects, and `@typert schema` data roots. Services and objects expose only public instance members, excluding constructors and static, private, and protected members; inheritance edges remain in TypeGraph instead of being copied into flattened members. When a public property, parameter, or return type lacks an annotation, `check` mode reports an error, while `write` mode writes the checker-inferred result, rebuilds the project, and analyzes it again in strict mode.
 
-[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas.
+[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and optional Zod schema factories, and Cordis effect disposal revokes it. The registry validates factories without invoking them, then materializes and caches each schema on its first `get()`, `resolve()`, `list()`, or JSON Schema projection. It neither analyzes TypeScript nor merges the two faces.
 
 Package artifact publication remains explicit opt-in through package exports. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`; it never edits those exports. The later [Typert Remote design](2026-08-02-typert-remote-method-calls.md) adds a whole-workspace Host contract pass to root build, typecheck, lint, and documentation typecheck. For opted-in Host packages, that pass emits both local reflection and strict Host-for-Client `/remote` contracts before consumers resolve them. Generated local declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
 
@@ -34,7 +34,7 @@ For every property in `SyntaxZoo`, the TypeScript printer normalizes the source
 
 Boundary cases pin explicit package imports within and across faces, cross-face named re-exports, exact export aliases, qualified `import()` links, and the External classification of global `@types` declarations; they reject TypeScript diagnostics originating in package-owned files, relative-path boundary crossings, references outside `package.json#exports`, and cross-face namespace re-exports without a model target. Interface declaration merging explicitly preserves every authored part; other merges that cannot be represented losslessly fail.
 
-For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute the JavaScript, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts.
+For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute each schema factory, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, first-use materialization, successful-result caching, retry after factory failure, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts.
 
 ## Alternatives considered
 
@@ -50,4 +50,4 @@ For each supported node kind and literal category, Zod emitter tests run both su
 
 New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
 
-Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
+Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them.

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md

@@ -20,7 +20,7 @@ TypeGraph 保存开发者写下的计算前类型结构,包括泛型参数与
 
 PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@typert schema` 数据根。service 与 object 只暴露 public instance member,排除 constructor、static、private 和 protected;继承边保留在 TypeGraph 中,不复制为扁平成员。缺少 public property、parameter 或 return 类型标注时,`check` 模式报错,`write` 模式写入 checker 推断结果后重建 project 并再次以严格模式分析。
 
-[`dsh-typert-registry`](../../../../packages/typert/registry/README.zh.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影
+[`dsh-typert-registry`](../../../../packages/typert/registry/README.zh.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema factory,并随 Cordis effect 撤销。注册表校验 factory 时不会调用它;首次 `get()`、`resolve()`、`list()` 或 JSON Schema 投影才会物化并缓存各 schema。注册表不分析 TypeScript,也不合并两个 face
 
 包产物发布仍通过 package exports 采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`;它不会修改这些 exports。后续的 [Typert Remote 设计](2026-08-02-typert-remote-method-calls.zh.md) 为根目录 build、typecheck、lint 与文档类型检查增加了全仓 Host 约定 pass。对于已 opt-in 的 Host 包,该 pass 会在消费方解析两者之前生成本地反射产物与严格的 Host-for-Client `/remote` 约定。生成的本地声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
 
@@ -34,7 +34,7 @@ PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@
 
 边界用例固定同 face 与跨 face 的显式包导入、跨 face 命名 re-export、精确 export alias、qualified `import()` link 和全局 `@types` External 归属,并拒绝 package 自有 TypeScript 诊断、相对路径越界、`package.json#exports` 之外的引用,以及尚无模型 target 的跨 face namespace re-export。interface declaration merging 显式保留每个 authored part,无法无损表示的其他 merge 失败。
 
-Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行 JavaScript,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。
+Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行每个 schema factory,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、首次使用物化、成功结果缓存、factory 失败后重试、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。
 
 ## Alternatives considered
 
@@ -50,4 +50,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
 
 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
 
-包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
+包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。

+ 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: 73ab996d408c71ab70d25058677d0d02efe05804
-2026-08-02-typert-remote-method-calls.zh.md: 06b3f9ad454ca905d33e8d08dde51e6c4e99427e
+2026-08-02-typert-remote-method-calls.md: b6551e1c7f8c94fb02a788aa62cb4acef1addffe
+2026-08-02-typert-remote-method-calls.zh.md: 058ec47e6749ee7576fd84fdcacfda350eec3fb8

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

@@ -147,7 +147,7 @@ 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 Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." 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." 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.
 
 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.
 

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

@@ -147,7 +147,7 @@ InvocationDescriptor {
 
 参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。
 
-LIB codec 带有 Zod schema 和「package + 公共 subpath + export name」的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 Typert 模型和 symbol key 生成。
+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 生成。
 
 descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.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-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: 89f89784512b86b70ee1b9460850e6f2f951a082
-2026-08-15-client-shells-and-dynamic-packages.zh.md: 5512d9262e979a94a65c25a6c1271b030e23ec70
+2026-08-15-client-shells-and-dynamic-packages.md: ad2bb26b84253e859e567775eec3b06d73b0003b
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 70563962b3c613caa611c61a0c690d2e0e777def

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -46,13 +46,17 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
 The modules Node half injects the startup protocol into the served HTML in this order:
 
 1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
-2. Start preloading every content-addressed application combo URL containing the rows other than modules.
+2. Start preloading every revisioned application combo URL containing the rows other than modules.
 3. Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 5. Execute the Vite main module.
 
 The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns `ClientModuleSystem`, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's `internal`, and the modules plugin reads it there when it provides `ctx.modules`. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity.
 
+The Host publishes graph and combo descriptors without concatenating response bodies. Each script URL shares one lazy Promise that concatenates its captured bundle bytes on first `GET` and appends the corresponding map URL; each map URL has a separate lazy Promise that reads and composes source maps only on its first `GET`. `HEAD` requests trigger neither body. The Web URL remains gated by Loader settlement and the required-entry audit, but that readiness point does not materialize combo bodies; an index request reads the current graph.
+
+The theme Host contribution is prepended to index collection. CSS in the head selects the initial document canvas palette, using `prefers-color-scheme` directly for the `system` preference; a body script applies the existing palette attribute and font-size variable before the loading page and application module.
+
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 
 ### Dependency declarations
@@ -79,7 +83,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
 
 Bundle contents stay stable when an internal DSH relationship is development-only, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. The shipped profile owns the complete Client package roster, so individual Client packages do not ask npm to solve the same graph again through peer placement.
 
-The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
+The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. Deferring response bodies moves concatenation to first access, while separately deferring maps keeps debugger-only work off script delivery. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
 
 The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
 

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -46,13 +46,17 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
 Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 
 1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue`、`load()` 与 `create()`。
-2. 开始预加载所有按内容寻址的 application combo URL,其中包含 modules 之外的 row。
+2. 开始预加载所有带 revision 的 application combo URL,其中包含 modules 之外的 row。
 3. 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 5. 执行 Vite 主模块。
 
 Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 `internal`,modules 插件从这里读取并提供 `ctx.modules`。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份。
 
+Host 发布 graph 与 combo descriptor 时不会拼接响应 body。每个脚本 URL 共用一个惰性 Promise,在首次 `GET` 时拼接捕获的 bundle 字节并追加对应的 map URL;每个 map URL 使用另一个惰性 Promise,只在首次 `GET` 时读取并组合 source map。`HEAD` 不触发任一 body。Web URL 仍由 Loader 结算和 required-entry audit 控制,但这个就绪点不会物化 combo body;index 请求读取当时的最新 graph。
+
+Theme 的 Host 贡献会前置到 index 收集顺序。head 中的 CSS 选择初始文档画布调色板,`system` 偏好直接使用 `prefers-color-scheme`;body 脚本在加载页面和应用模块之前应用既有的调色板属性与字号变量。
+
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 
 ### 依赖声明
@@ -79,7 +83,7 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外
 
 内部 DSH 关系仅放在开发区段时,bundle 内容仍保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。发布 profile 拥有完整 Client 包名册,因此各 Client 包不再要求 npm 通过 peer placement 重复求解同一张图。
 
-启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
+启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。响应 body 的延迟生成会把拼接移到首次访问,而 map 的独立延迟生成会让仅供调试器使用的工作不进入脚本交付路径。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
 
 外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: 77b73b8c461238ad80afd7abd609a68df0bcf058
-2026-08-10-npm-release-sequences.zh.md: f56234fc50d776658d2d9191056b0b04e1609f1e
+2026-08-10-npm-release-sequences.md: 6f52c00a31870939981994965b70504e8f8143ca
+2026-08-10-npm-release-sequences.zh.md: effa5538cefbc8f25b2e047ee40e0dc7306bb2fb

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -96,6 +96,8 @@ A dependency in `optionalDependencies`, or a peer carrying `peerDependenciesMeta
 
 A violation names the package, the declaration that made it optional, and the way out in order — import it as a type, which is all that declaration merging needs, or restructure so module scope does not need the package. A dynamic `import()` only moves the failure to first use, so it belongs to a caller that genuinely requires the package and handles its absence; reaching for it is a sign the dependency is not optional, and the gate does not offer it as the remedy.
 
+A required CommonJS-compatible Host dependency whose initialization is unrelated to startup may use `createLazyRequire(specifier, import.meta.url)`. The caller keeps a type-only import, supplies a literal dependency specifier, and invokes the returned loader at the owning operation. `verify-package-dependencies` recognizes that literal as a Host runtime edge, so Client/Host packages retain it in `dependencies` even though no static value import remains. The utility caches only a successful load and preserves caller-relative resolution; it does not make an optional dependency required or hide first-use failure.
+
 ### Release family objects
 
 The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core.

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -96,6 +96,8 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
 
 报错会点名这个包、点名是哪条声明把它标成 optional 的,并按顺序给出出路——把它作为类型引入(声明合并需要的仅此而已),或者调整写法让模块作用域不再需要这个包。动态 `import()` 只是把失败推迟到首次使用,它属于那种确实需要这个包、并且自己处理缺失的调用方;会想到它,往往说明这个依赖并不 optional,所以门禁不把它作为解法给出。
 
+初始化与启动无关、且兼容 CommonJS 的必需 Host 依赖可以使用 `createLazyRequire(specifier, import.meta.url)`。调用方保留 type-only import,传入字面量依赖 specifier,并在所属操作中调用返回的 loader。`verify-package-dependencies` 会把该字面量识别为 Host runtime edge,因此 Client/Host 包即使没有静态值 import,仍会把它保留在 `dependencies`。该工具只缓存成功加载,并保留调用方相对解析;它不会把 optional 依赖变成必需依赖,也不会隐藏首次使用失败。
+
 ### 发布族对象
 
 这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。

+ 1 - 1
apps/web/tests/settings-chrome.e2e.ts

@@ -274,7 +274,7 @@ describe('web e2e: settings modal and General preferences', () => {
         return {
           attr: document.body.hasAttribute('data-ds-dark-theme'),
           background: getComputedStyle(boot).backgroundColor,
-          colorScheme: document.documentElement.style.colorScheme,
+          colorScheme: getComputedStyle(document.documentElement).colorScheme,
         }
       })
       expect(state).toEqual({

+ 1 - 0
benchmarks/package.json

@@ -19,6 +19,7 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-deque": "workspace:^",
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-sdk-client": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ca95f08dc7dd405422edeac13d716f2842d4388f
-config-catalog.zh.md: 91dc6b4e5d47776672f5f89a500460b7ef325b8a
+config-catalog.md: fd28a79ad329d67698da6f92b1a675d67475df7b
+config-catalog.zh.md: eeb90df8eaa52673e5dfb2faf68d9f4b57ba6891

+ 1 - 0
docs/config-catalog.md

@@ -3730,6 +3730,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-http-proxy` ([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts))
 - `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
+- `@deepseek-ai/dsh-lazy-require` ([`packages/util/lazy-require/src/index.ts`](../packages/util/lazy-require/src/index.ts))
 - `@deepseek-ai/dsh-llm-mock-server` ([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))

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

@@ -3731,6 +3731,7 @@ export interface Config {
 - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-http-proxy`([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts))
 - `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
+- `@deepseek-ai/dsh-lazy-require`([`packages/util/lazy-require/src/index.ts`](../packages/util/lazy-require/src/index.ts))
 - `@deepseek-ai/dsh-llm-mock-server`([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))

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

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

+ 2 - 0
docs/module-graph.md

@@ -15,6 +15,7 @@ flowchart TD
     pkg_home_paths["home-paths"]
     pkg_http_proxy["http-proxy"]
     pkg_launch_environment["launch-environment"]
+    pkg_lazy_require["lazy-require"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_package_manifest["package-manifest"]
@@ -1280,6 +1281,7 @@ flowchart TD
 | [`home-paths`](../packages/util/home-paths) | `util` | — |
 | [`http-proxy`](../packages/util/http-proxy) | `util` | — |
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
+| [`lazy-require`](../packages/util/lazy-require) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
 | [`package-manifest`](../packages/util/package-manifest) | `util` | — |

+ 2 - 0
docs/module-graph.zh.md

@@ -17,6 +17,7 @@ flowchart TD
     pkg_home_paths["home-paths"]
     pkg_http_proxy["http-proxy"]
     pkg_launch_environment["launch-environment"]
+    pkg_lazy_require["lazy-require"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_package_manifest["package-manifest"]
@@ -1282,6 +1283,7 @@ flowchart TD
 | [`home-paths`](../packages/util/home-paths) | `util` | — |
 | [`http-proxy`](../packages/util/http-proxy) | `util` | — |
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
+| [`lazy-require`](../packages/util/lazy-require) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
 | [`package-manifest`](../packages/util/package-manifest) | `util` | — |

+ 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: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0
-client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27
+client-modules.md: 74aa2b4983f3c8c7fc6b3ba9b03646ac8141f3f1
+client-modules.zh.md: 259717a44a3275ef5b94df698fc01b5de7431fd0

+ 11 - 10
docs/subsystems/client-modules.md

@@ -36,7 +36,7 @@ interface WebBootEntry {
 ```
 
 ```ts type-equiv
-/** Initial scheduling phase for one content-addressed combo script. */
+/** Initial scheduling phase for one revisioned combo script. */
 type WebBootBatchPhase = 'bootstrap' | 'application'
 ```
 
@@ -45,9 +45,9 @@ type WebBootBatchPhase = 'bootstrap' | 'application'
 interface WebBootBatch {
   /** Parser-blocking bootstrap or preloaded application scheduling. */
   phase: WebBootBatchPhase
-  /** Content-addressed combo script endpoint. */
+  /** Revisioned combo script endpoint. */
   url: string
-  /** Revision over the combined plugin script bytes and indexed source map. */
+  /** Revision derived from the ordered entry revisions. */
   rev: string
   /** Graph entry ids whose factories the script registers, in execution order. */
   entries: string[]
@@ -57,7 +57,7 @@ interface WebBootBatch {
 ```ts type-equiv
 /** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
 interface WebBootGraph {
-  /** Consistency anchor over the whole graph (content + bundle hashes). */
+  /** Consistency anchor over the current entry and batch descriptors. */
   rev: string
   /**
    * Composed entries in module-graph order — a dynamic package row precedes
@@ -70,7 +70,7 @@ interface WebBootGraph {
 }
 ```
 
-Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and revision; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB. Startup combo revisions hash the combined plugin script bytes and indexed source map, and the graph revision hashes both rows and descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
+Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every plugin artifact. After HMR observes a bundle change, that row's revision becomes the hash of its new executable bytes. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and a revision derived from those row revisions; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB, without concatenating scripts or reading maps. The graph revision hashes the entry and batch descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
 
 ## The scan
 
@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
 
 ## The bundle route and index injection
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` serves an exact generated combo script; a one-resource request uses the same form and is the HMR path. Its absolute `sourceMappingURL` changes every resource suffix in parallel, yielding `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. The map is Indexed Source Map v3 even for one resource. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the generated bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. All advertised responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with an absolute `sourceMappingURL` whose resource suffixes are `.js.map`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
 
 ## The service
 
@@ -98,9 +98,9 @@ interface ClientArtifactBaseline {
 }
 ```
 
-`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
+`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
-In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files.
+In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts revision changes to the browser half over SSE. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Production graphs omit the HMR row entirely; the module host itself never watches files.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -133,11 +133,12 @@ clientPath(id: string): string | undefined
 /**
  * Serve an advertised revisioned bundle or source map without a Web server.
  * Unknown URLs return 404, unsupported methods return 405, and `HEAD`
- * returns the same immutable headers without a body.
+ * returns the same immutable headers without materializing a body. Each body
+ * is built once on its first `GET`; script construction never reads maps.
  * @param request - shell-carrier request for a `/plugins` resource.
  * @returns the exact response also exposed by the optional Web route.
  */
-fetchBundle(request: Request): Response
+async fetchBundle(request: Request): Promise<Response>
 
 /**
  * Filesystem baseline captured before an entry's current bytes were read.

+ 11 - 10
docs/subsystems/client-modules.zh.md

@@ -36,7 +36,7 @@ interface WebBootEntry {
 ```
 
 ```ts type-equiv
-/** Initial scheduling phase for one content-addressed combo script. */
+/** Initial scheduling phase for one revisioned combo script. */
 type WebBootBatchPhase = 'bootstrap' | 'application'
 ```
 
@@ -45,9 +45,9 @@ type WebBootBatchPhase = 'bootstrap' | 'application'
 interface WebBootBatch {
   /** Parser-blocking bootstrap or preloaded application scheduling. */
   phase: WebBootBatchPhase
-  /** Content-addressed combo script endpoint. */
+  /** Revisioned combo script endpoint. */
   url: string
-  /** Revision over the combined plugin script bytes and indexed source map. */
+  /** Revision derived from the ordered entry revisions. */
   rev: string
   /** Graph entry ids whose factories the script registers, in execution order. */
   entries: string[]
@@ -57,7 +57,7 @@ interface WebBootBatch {
 ```ts type-equiv
 /** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
 interface WebBootGraph {
-  /** Consistency anchor over the whole graph (content + bundle hashes). */
+  /** Consistency anchor over the current entry and batch descriptors. */
   rev: string
   /**
    * Composed entries in module-graph order — a dynamic package row precedes
@@ -70,7 +70,7 @@ interface WebBootGraph {
 }
 ```
 
-每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。初始描述把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条描述。URL 只含有序 package 资源列表与 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分。启动 combo revision 对合并后的插件脚本字节与 indexed sourcemap 求哈希,图 revision 则对 row 与描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
+每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个插件产物。HMR 观察到 bundle 变化后,该 row 的 revision 才改为新可执行字节的哈希。初始 descriptor 把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条 descriptor。URL 只含有序 package 资源列表和从这些 row revision 派生的 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分,不拼接脚本,也不读取 map。图 revision 对 entry 与 batch descriptor 求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
 
 ## 扫描
 
@@ -82,7 +82,7 @@ interface WebBootGraph {
 
 ## bundle 路由与 index 注入
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 提供精确生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。其绝对 `sourceMappingURL` 平行改写每个资源后缀,得到 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。即使只有一个资源,map 仍采用 Indexed Source Map v3。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是构建后 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。所有已发布响应都使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以绝对 `sourceMappingURL` 结尾,其中每个资源后缀改为 `.js.map`。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
 
 ## 服务
 
@@ -98,9 +98,9 @@ interface ClientArtifactBaseline {
 }
 ```
 
-`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
+`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
+开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 revision 变化广播给浏览器半。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -133,11 +133,12 @@ clientPath(id: string): string | undefined
 /**
  * Serve an advertised revisioned bundle or source map without a Web server.
  * Unknown URLs return 404, unsupported methods return 405, and `HEAD`
- * returns the same immutable headers without a body.
+ * returns the same immutable headers without materializing a body. Each body
+ * is built once on its first `GET`; script construction never reads maps.
  * @param request - shell-carrier request for a `/plugins` resource.
  * @returns the exact response also exposed by the optional Web route.
  */
-fetchBundle(request: Request): Response
+async fetchBundle(request: Request): Promise<Response>
 
 /**
  * Filesystem baseline captured before an entry's current bytes were read.

+ 2 - 2
docs/subsystems/typert.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/typert.md
-typert.md: 0f3d2b1afdc1b7713402abc5885dc9551e16ac14
-typert.zh.md: a3c06489707e18433ed9018363c57ec76732e606
+typert.md: 954c3dfb7e66b100132c6d4604c28f06fba04916
+typert.zh.md: dd2becdc43871ddbecdfb45909e076435ab5a447

+ 6 - 5
docs/subsystems/typert.md

@@ -38,7 +38,7 @@ interface TypertLookupDefinition {
 
 ## Invocation descriptors
 
-An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`.
+An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`.
 
 ```ts type-equiv
 /** Codec attached to one invocation parameter or result. */
@@ -46,7 +46,8 @@ type TypertCodec =
   | {
     readonly mode: 'strict'
     readonly typeSymbol: string
-    readonly schema: TypertSchema
+    /** Materialize and return the process-realm schema on first boundary use. */
+    readonly create: () => TypertSchema
   }
   | {
     readonly mode: 'src-json'
@@ -261,14 +262,14 @@ register(contribution: TypertContribution): TypertDisposer
 /**
  * Look up one schema by `<package>#<name>`.
  * @param key - global schema key.
- * @returns the live schema record, or `undefined` when absent.
+ * @returns a record containing the cached schema, or `undefined` when absent.
  */
 get(key: string): TypertSchemaRecord | undefined
 
 /**
  * Resolve one required schema.
  * @param key - global schema key.
- * @returns the live schema record.
+ * @returns a record containing the cached schema.
  * @throws when the key is malformed, the package face is absent, or the schema is not contributed.
  */
 resolve(key: string): TypertSchemaRecord
@@ -276,7 +277,7 @@ resolve(key: string): TypertSchemaRecord
 /**
  * Enumerate live schemas in registration order.
  * @param filter - optional package and face restriction.
- * @returns matching schema records.
+ * @returns matching records containing the cached schemas.
  */
 list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]
 

+ 6 - 5
docs/subsystems/typert.zh.md

@@ -38,7 +38,7 @@ interface TypertLookupDefinition {
 
 ## 调用 descriptor
 
-`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。
+`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。
 
 ```ts type-equiv
 /** Codec attached to one invocation parameter or result. */
@@ -46,7 +46,8 @@ type TypertCodec =
   | {
     readonly mode: 'strict'
     readonly typeSymbol: string
-    readonly schema: TypertSchema
+    /** Materialize and return the process-realm schema on first boundary use. */
+    readonly create: () => TypertSchema
   }
   | {
     readonly mode: 'src-json'
@@ -261,14 +262,14 @@ register(contribution: TypertContribution): TypertDisposer
 /**
  * Look up one schema by `<package>#<name>`.
  * @param key - global schema key.
- * @returns the live schema record, or `undefined` when absent.
+ * @returns a record containing the cached schema, or `undefined` when absent.
  */
 get(key: string): TypertSchemaRecord | undefined
 
 /**
  * Resolve one required schema.
  * @param key - global schema key.
- * @returns the live schema record.
+ * @returns a record containing the cached schema.
  * @throws when the key is malformed, the package face is absent, or the schema is not contributed.
  */
 resolve(key: string): TypertSchemaRecord
@@ -276,7 +277,7 @@ resolve(key: string): TypertSchemaRecord
 /**
  * Enumerate live schemas in registration order.
  * @param filter - optional package and face restriction.
- * @returns matching schema records.
+ * @returns matching records containing the cached schemas.
  */
 list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]
 

+ 1 - 1
packages/api/gateway/src/client/index.ts

@@ -717,7 +717,7 @@ function parseInput(codec: TypertCodec, value: unknown, endpoint: string, field:
     throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
   }
   try {
-    return codec.schema.parse(value)
+    return codec.create().parse(value)
   } catch (cause) {
     throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
   }

+ 1 - 1
packages/api/gateway/src/index.ts

@@ -1140,7 +1140,7 @@ function decode(
 ): unknown {
   try {
     if (codec.mode === 'strict') {
-      value = codec.schema.parse(value)
+      value = codec.create().parse(value)
       /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */
       if (value === undefined) return value
     }

+ 3 - 3
packages/api/gateway/tests/gateway-stream.host.spec.ts

@@ -954,7 +954,7 @@ function descriptors(): InvocationDescriptor[] {
     name: 'label',
     wire: 'label',
     source: 'json' as const,
-    codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() },
+    codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() },
   }
   const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({
     id: `@fixture/feed#feed/${method}`,
@@ -964,7 +964,7 @@ function descriptors(): InvocationDescriptor[] {
     mode: 'stream',
     invocation: { kind: 'direct' },
     parameters,
-    result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema },
+    result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema },
   })
   return [
     { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
@@ -982,7 +982,7 @@ function descriptors(): InvocationDescriptor[] {
       method: 'unary',
       invocation: { kind: 'direct' },
       parameters: [label],
-      result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() },
+      result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() },
     },
   ]
 }

+ 12 - 12
packages/api/gateway/tests/gateway.client.spec.ts

@@ -152,15 +152,15 @@ function directDescriptor(): InvocationDescriptor {
       wire: 'agentId',
       source: 'lookup',
       lookup: 'fixture',
-      codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
+      codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', create: () => idSchema },
     }, {
       name: 'request',
       wire: 'request',
       source: 'json',
-      codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
+      codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', create: () => requestSchema },
     }],
     cancellation: { parameter: 'signal' },
-    result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
+    result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', create: () => createResultSchema },
   }
 }
 
@@ -174,15 +174,15 @@ function contextDescriptor(): InvocationDescriptor {
       kind: 'context',
       context: 'fixture',
       wire: 'agentId',
-      codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
+      codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', create: () => idSchema },
     },
     parameters: [{
       name: 'request',
       wire: 'request',
       source: 'json',
-      codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
+      codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', create: () => requestSchema },
     }],
-    result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
+    result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', create: () => renameResultSchema },
   }
 }
 
@@ -199,9 +199,9 @@ function maybeDescriptor(): InvocationDescriptor {
       wire: 'value',
       source: 'json',
       acceptsUndefined: true,
-      codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
+      codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', create: () => schema },
     }],
-    result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
+    result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', create: () => schema },
   }
 }
 
@@ -217,10 +217,10 @@ function streamDescriptor(): InvocationDescriptor {
       name: 'topic',
       wire: 'topic',
       source: 'json',
-      codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) },
+      codec: { mode: 'strict', typeSymbol: '@fixture#Topic', create: () => z.string().min(1) },
     }],
     cancellation: { parameter: 'signal' },
-    result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) },
+    result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', create: () => z.string().min(1) },
   }
 }
 
@@ -1155,7 +1155,7 @@ describe('Client Typert API', () => {
         ...direct,
         parameters: [...direct.parameters, {
           name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
-          codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
+          codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', create: () => idSchema },
         }],
       }],
     })).rejects.toThrow('scope must select its only lookup parameter')
@@ -1271,7 +1271,7 @@ describe('Client Typert API', () => {
         name: 'value',
         wire: '__proto__',
         source: 'json',
-        codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
+        codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', create: () => z.string() },
       }],
     }
     const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })

+ 1 - 1
packages/api/gateway/tests/gateway.host.spec.ts

@@ -1349,7 +1349,7 @@ function contextProvider(context: Context) {
 }
 
 function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] {
-  return { mode: 'strict', typeSymbol, schema }
+  return { mode: 'strict', typeSymbol, create: () => schema }
 }
 
 function createDescriptor(): InvocationDescriptor {

+ 1 - 0
packages/api/terminal-controller/package.json

@@ -55,6 +55,7 @@
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/dsh-deque": "workspace:^",
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "@xterm/headless": "^6.0.0",

+ 5 - 10
packages/api/terminal-controller/src/terminal.ts

@@ -1,21 +1,14 @@
 /** One PTY, a bounded terminal emulator and its detachable browser followers. */
-import { createRequire } from 'node:module'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { Terminal as HeadlessTerminal } from '@xterm/headless'
 import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import { TerminalFollower } from './stream.ts'
 import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts'
 
-const { Terminal, SerializeAddon } = loadXterm()
-
-function loadXterm() {
-  // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries.
-  const require = createRequire(import.meta.url)
-  const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless')
-  const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize')
-  return { Terminal, SerializeAddon }
-}
+const requireHeadless = createLazyRequire<typeof import('@xterm/headless')>('@xterm/headless', import.meta.url)
+const requireSerialize = createLazyRequire<typeof import('@xterm/addon-serialize')>('@xterm/addon-serialize', import.meta.url)
 
 /** Process lifetime is independent of follower and component lifetimes. */
 export class BrowserTerminal {
@@ -40,6 +33,8 @@ export class BrowserTerminal {
     scrollback: number,
     private readonly maxBufferedBytes: number,
   ) {
+    const { Terminal } = requireHeadless()
+    const { SerializeAddon } = requireSerialize()
     this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true })
     this.serializer = new SerializeAddon()
     this.screen.loadAddon(this.serializer)

+ 3 - 0
packages/api/terminal-controller/tsconfig.host.json

@@ -48,6 +48,9 @@
     },
     {
       "path": "../../util/deque"
+    },
+    {
+      "path": "../../util/lazy-require"
     }
   ]
 }

+ 1 - 0
packages/attachment/attachment-local/package.json

@@ -26,6 +26,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "sharp": "^0.35.3"
   },

+ 4 - 1
packages/attachment/attachment-local/src/image.ts

@@ -1,8 +1,9 @@
 /** Raster inspection: full decode at admission, header-only probe on verified reads. */
 
-import sharp, { type Sharp } from 'sharp'
+import type { Sharp } from 'sharp'
 import { AttachmentError } from '@deepseek-ai/dsh-attachment'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
+import { requireSharp } from './sharp.ts'
 
 /** Decoded metadata from a supported image. */
 export interface DetectedImage {
@@ -89,6 +90,7 @@ async function imageMetadata(image: Sharp): Promise<DetectedImage> {
  * @returns verified format and dimensions.
  */
 export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
+  const sharp = requireSharp()
   try {
     return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
   } catch (error) {
@@ -112,6 +114,7 @@ export interface DecodedImageLimits {
  * @returns verified format and dimensions.
  */
 export async function detectImage(data: Uint8Array, limits?: DecodedImageLimits): Promise<DetectedImage> {
+  const sharp = requireSharp()
   try {
     const image = sharp(data, { failOn: 'error', limitInputPixels: false })
     const detected = await imageMetadata(image)

+ 10 - 3
packages/attachment/attachment-local/src/normalization.ts

@@ -1,11 +1,12 @@
 /** Deterministic provider-independent image normalization. */
 
-import sharp, { type Sharp } from 'sharp'
+import type { Sharp } from 'sharp'
 import { AttachmentError, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts'
 import { detectImage, encodedAlphaIsCompatible } from './image.ts'
 import type { DetectedImage } from './image.ts'
+import { requireSharp } from './sharp.ts'
 
 /** Deployment-resolved policy for the persisted normalized attachment. */
 export interface NormalizationPolicy {
@@ -70,7 +71,12 @@ async function verifyNormalizedImage(
 }
 
 /** Build one fixed-size, oriented, metadata-free sRGB pipeline from submitted bytes. */
-function preparedPipeline(data: Uint8Array, width: number, height: number): Sharp {
+function preparedPipeline(
+  sharp: ReturnType<typeof requireSharp>,
+  data: Uint8Array,
+  width: number,
+  height: number,
+): Sharp {
   return sharp(data, { failOn: 'error', limitInputPixels: false })
     .rotate()
     .toColourspace('srgb')
@@ -108,10 +114,11 @@ export async function normalizeImage(
   if (canPassThroughNormalization(detected, data.byteLength, policy)) {
     return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height }
   }
+  const sharp = requireSharp()
   try {
     const { width, height } = initialDimensions(detected, policy)
     const encoded = await encodeFirstWithinLimit(
-      encodingLadder(preparedPipeline(data, width, height), detected.hasAlpha),
+      encodingLadder(preparedPipeline(sharp, data, width, height), detected.hasAlpha),
       policy.maxBytes,
     )
     const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded

+ 3 - 1
packages/attachment/attachment-local/src/request-image.ts

@@ -3,7 +3,7 @@
 import { createHash, randomUUID } from 'node:crypto'
 import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
-import sharp, { type Sharp } from 'sharp'
+import type { Sharp } from 'sharp'
 import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type {
   ImageMediaType,
@@ -20,6 +20,7 @@ import {
   isExhaustedEncoding,
 } from './encoding.ts'
 import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts'
+import { requireSharp } from './sharp.ts'
 
 /** Transform version included in every cache and upload-index identity. */
 export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6'
@@ -90,6 +91,7 @@ function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget)
 }
 
 function sourcePipeline(attachment: StoredImageAttachment): Sharp {
+  const sharp = requireSharp()
   return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb')
 }
 

+ 7 - 0
packages/attachment/attachment-local/src/sharp.ts

@@ -0,0 +1,7 @@
+/** Process-realm lazy access to Sharp's CommonJS-compatible entry. */
+
+import type sharp from 'sharp'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
+
+/** Load Sharp on the first raster operation and retain its callable export. */
+export const requireSharp = createLazyRequire<typeof sharp>('sharp', import.meta.url)

+ 40 - 0
packages/attachment/attachment-local/tests/lazy-sharp-failure.spec.ts

@@ -0,0 +1,40 @@
+import { afterAll, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({ failure: new Error('sharp native binding unavailable') }))
+
+vi.mock('../src/sharp.ts', () => ({
+  requireSharp: () => { throw state.failure },
+}))
+
+import { detectImage, probeImage } from '../src/image.ts'
+import { normalizeImage } from '../src/normalization.ts'
+
+afterAll(() => {
+  vi.doUnmock('../src/sharp.ts')
+})
+
+describe('lazy Sharp load failure', () => {
+  it.each([
+    ['header probe', probeImage],
+    ['full decode', detectImage],
+  ] as const)('does not classify a %s as invalid image data', async (_name, operation) => {
+    await expect(operation(Uint8Array.of(1, 2, 3))).rejects.toBe(state.failure)
+  })
+
+  it('does not classify normalization startup as an encoding failure', async () => {
+    await expect(normalizeImage(Uint8Array.of(1, 2, 3), {
+      mediaType: 'image/png',
+      width: 3,
+      height: 2,
+      animated: false,
+      carriesMetadata: false,
+      depth: 'ushort',
+      space: 'rgb16',
+      hasAlpha: true,
+    }, {
+      maxPixels: 100,
+      maxDimension: 10,
+      maxBytes: 1_024,
+    })).rejects.toBe(state.failure)
+  })
+})

+ 2 - 1
packages/attachment/attachment-local/tsconfig.json

@@ -7,6 +7,7 @@
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/schemastery" },
     { "path": "../attachment" },
-    { "path": "../../util/home-paths" }
+    { "path": "../../util/home-paths" },
+    { "path": "../../util/lazy-require" }
   ]
 }

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: 23ff6e925eb097eb99e5fa7ce8678c87c61aeb9c
-README.zh.md: 79b6335798e2bf522ce2bc1e201be745bb91e085
+README.md: c35bdf219c4c231e2ffde73a592b04070f96a4bc
+README.zh.md: 9a4047525da16cfb8249e4732fc2102f95b9d89f

+ 1 - 1
packages/bundle/web-app/README.md

@@ -81,7 +81,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve
 
 ### Readiness
 
-The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
+The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Client combo JavaScript and source maps remain unmaterialized at this point. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
 
 ### LAN trust sampling
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -81,7 +81,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
 
 ### 就绪宣告
 
-URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
+URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。此时 client combo JavaScript 和 source map 仍未物化。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
 
 ### LAN 信任采样
 

+ 3 - 2
packages/bundle/web-app/cordis.patch.yml

@@ -145,8 +145,9 @@
     # assembly fact of dsh-web-app, never user config), mounts the
     # frontend-static fallback owner, registers the web-surface prompt
     # section and the bash runtime variable, prints the URL line, and opens the
-    # canonical local URL after the full tree settles. The webStartup provider
-    # supplies invocation-only values; after the server binds, this row samples
+    # canonical local URL after the full Loader tree settles and the required-
+    # entry audit passes. The
+    # webStartup provider supplies invocation-only values; after the server binds, this row samples
     # LAN trust once and provides `webRuntime`. A complete agent-preset persona
     # suppresses the prompt section for that agent while retaining the host-owned
     # shell variable.

+ 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: c83796fc8d6c364b9fad6d44e25316e2c2f7e7af
-README.zh.md: 9ffb68ebc07b5970edc45a54c4cfde7cdf4ef7d8
+README.md: ef303761b164c2d75d1757d3bce1810b4795c12b
+README.zh.md: 6c4c80e90ed2209901894b4d43ac28ad3b57e61e

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

@@ -67,7 +67,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
 
 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 and available source map before publication. 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. 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, so startup does not hash every plugin; HMR hashes only an artifact reported as changed. Advertised responses are immutable, and an unknown combination or revision returns 404.
+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.
 
 ### Boot manifest injection
 
@@ -119,7 +119,8 @@ These limits define what the module system does not do. They are current package
 
 - **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
 - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
-- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
+- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
+- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
 
 <a id="dev-note"></a>
 ### Dev Note

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

@@ -67,7 +67,7 @@ application combo 脚本在启动时仅注册一次插件 factory;模块主体
 
 Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
 
-Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,所以启动时不哈希每个插件;HMR 只哈希被报告为已变化的产物。已公告响应不可变;未知组合或 revision 返回 404。
+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。
 
 ### 启动 manifest 注入
 
@@ -119,7 +119,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 
 - **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
 - **自身不维护卸载记录**——样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
-- **快照式提供会保留产物字节**——Host 在内存中保留每个 bundle、可选 source map、生成的单资源响应和当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随已组合客户端产物增长为数份副本,以换取不可变响应和一代竞态容忍。
+- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
+- **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 5 - 5
packages/client/modules/src/client/manifest.ts

@@ -63,16 +63,16 @@ export interface WebBootEntry {
   external?: string[]
 }
 
-/** Initial scheduling phase for one content-addressed combo script. */
+/** Initial scheduling phase for one revisioned combo script. */
 export type WebBootBatchPhase = 'bootstrap' | 'application'
 
 /** One initial combo script; a scheduling phase may span several descriptors. */
 export interface WebBootBatch {
   /** Parser-blocking bootstrap or preloaded application scheduling. */
   phase: WebBootBatchPhase
-  /** Content-addressed combo script endpoint. */
+  /** Revisioned combo script endpoint. */
   url: string
-  /** Revision over the combined plugin script bytes and indexed source map. */
+  /** Revision derived from the ordered entry revisions. */
   rev: string
   /** Graph entry ids whose factories the script registers, in execution order. */
   entries: string[]
@@ -80,7 +80,7 @@ export interface WebBootBatch {
 
 /** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
 export interface WebBootGraph {
-  /** Consistency anchor over the whole graph (content + bundle hashes). */
+  /** Consistency anchor over the current entry and batch descriptors. */
   rev: string
   /**
    * Composed entries in module-graph order — a dynamic package row precedes
@@ -98,7 +98,7 @@ export interface BootModuleRow {
   id: string
   /** Revisioned single-resource combo endpoint used after HMR invalidation. */
   url: string
-  /** Content-addressed combo endpoint used before the first HMR invalidation. */
+  /** Revisioned combo endpoint used before the first HMR invalidation. */
   initialUrl: string
   /** Opaque plugin-artifact revision used after HMR invalidation. */
   rev: string

+ 133 - 78
packages/client/modules/src/index.ts

@@ -139,22 +139,30 @@ interface WebPluginRecord {
   bundle: Buffer
   /** Pre-read filesystem baseline handed to the HMR watcher. */
   baseline: ClientArtifactBaseline
-  /** Optional authored source map snapshot; generated-file identity mapping is the fallback. */
-  sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
 }
 
-/** Fields shared by every generated combo response. */
-interface ComboArtifactBase {
-  url: string
+/** Immutable inputs captured for one resource in a generated combo. */
+interface ComboResource {
+  id: string
   rev: string
-  entries: string[]
-  script: Buffer
+  clientPath: string
+  bundle: Buffer
 }
 
-/** One generated combo response over an ordered list of plugin resources. */
-interface ComboArtifact extends ComboArtifactBase {
-  sourceMap: Buffer
+/** One lazily materialized immutable response body. */
+interface LazyResponse {
+  contentType: string
+  body: () => Promise<Buffer>
+}
+
+/** Fields shared by every generated combo plan. */
+interface ComboArtifact {
+  url: string
+  rev: string
+  entries: string[]
   sourceMapUrl: string
+  scriptBody: () => Promise<Buffer>
+  sourceMapBody: () => Promise<Buffer>
 }
 
 /** One generated initial-load response and its wire descriptor. */
@@ -197,9 +205,9 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
   return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
 }
 
-/** Hash every artifact input served after HMR observes one plugin change. */
-function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
-  return framedHash('plugin-artifact', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
+/** Hash the executable artifact served after HMR observes one plugin change. */
+function artifactRevision(bundle: Buffer): string {
+  return framedHash('plugin-artifact', [bundle])
 }
 
 /** Address one ordered plugin-file list through the shared combo route. */
@@ -245,19 +253,19 @@ function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRe
 }
 
 /** Executable source plus the generated-file name used when no authored map exists. */
-interface ComboSource {
+interface PreparedSource {
   source: string
   fallbackSource: string
 }
 
 /** Remove bundle-local debug directives and retain their stable generated-file name. */
-function comboSource(record: WebPluginRecord): ComboSource {
-  let source = record.bundle.toString('utf8')
+function prepareSource(resource: ComboResource): PreparedSource {
+  let source = resource.bundle.toString('utf8')
   const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
   source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
   if (!source.endsWith('\n')) source += '\n'
   const fallbackSource = sourceUrl === undefined
-    ? `/plugins/${record.entry.id}/client.js`
+    ? `/plugins/${resource.id}/client.js`
     : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
   return { source, fallbackSource }
 }
@@ -267,8 +275,8 @@ function comboScript(input: string, sourceMapUrl?: string): Buffer {
   return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
 }
 
-/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
-function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
+/** Read and parse an optional source map when its combo-map endpoint is requested. */
+function readSourceMap(clientPath: string): Record<string, unknown> | undefined {
   let body: Buffer
   try {
     body = readFileSync(`${clientPath}.map`)
@@ -289,7 +297,7 @@ function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
   ) {
     throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`)
   }
-  return { body, parsed }
+  return parsed
 }
 
 /** Count generated lines while assembling indexed-map section offsets. */
@@ -300,13 +308,10 @@ function newlineCount(value: string): number {
 }
 
 /** Resolve section sources against their original per-plugin map URL before combo relocation. */
-function comboSectionMap(record: WebPluginRecord): Record<string, unknown> {
-  const original = record.sourceMap?.parsed
-  /* v8 ignore next -- callers add sections only for records with a source map. */
-  if (original === undefined) throw new Error(`client-modules: source map missing for ${record.entry.id}`)
+function comboSectionMap(resource: ComboResource, original: Record<string, unknown>): Record<string, unknown> {
   const sourcePaths = original.sources as string[]
   const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
-  const base = new URL(`/plugins/${record.entry.id}/client.js.map`, 'http://dsh.invalid')
+  const base = new URL(`/plugins/${resource.id}/client.js.map`, 'http://dsh.invalid')
   const relocated = sourcePaths.map((source) => {
     const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
     const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
@@ -332,33 +337,88 @@ function identitySectionMap(source: string, sourceUrl: string): Record<string, u
   }
 }
 
-/** Concatenate one or more factory registrations and compose their maps as indexed sections. */
-function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
+/** Run one producer in the first requester's microtask, not off-thread, and share its settlement. */
+function lazyBody(produce: () => Buffer): () => Promise<Buffer> {
+  let result: Promise<Buffer> | undefined
+  return () => {
+    result ??= Promise.resolve().then(produce)
+    return result
+  }
+}
+
+/** Derive one combo revision from the ordered immutable row revisions. */
+function comboRevision(resources: readonly ComboResource[]): string {
+  return framedHash('combo', resources.flatMap(resource => [
+    Buffer.from(resource.id),
+    Buffer.from(resource.rev),
+  ]))
+}
+
+/** Concatenate one or more factory registrations without reading or composing source maps. */
+function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: string): Buffer {
   let source = ''
+  for (const resource of resources) source += `${prepareSource(resource).source};\n`
+  return comboScript(source, sourceMapUrl)
+}
+
+/** Compose one indexed map from source-map files read only for this request. */
+function buildComboSourceMap(
+  resources: readonly ComboResource[],
+  sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
+): Buffer {
   const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
   let line = 0
-  for (const record of records) {
-    const prepared = comboSource(record)
-    const section = record.sourceMap === undefined
-      ? identitySectionMap(prepared.source, prepared.fallbackSource)
-      : comboSectionMap(record)
+  for (const resource of resources) {
+    const prepared = prepareSource(resource)
+    const sourceMap = sourceMapOf(resource.clientPath)
+    let section = identitySectionMap(prepared.source, prepared.fallbackSource)
+    if (sourceMap !== undefined) {
+      try {
+        section = comboSectionMap(resource, sourceMap)
+      } catch {
+        // An invalid authored source URL is a malformed map, so the generated
+        // bundle remains debuggable through the same identity fallback.
+      }
+    }
     sections.push({ offset: { line, column: 0 }, map: section })
-    const bundle = `${prepared.source};\n`
-    source += bundle
-    line += newlineCount(bundle)
-  }
-  const sourceMap = Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
-  const sourceBytes = Buffer.from(source)
-  const rev = revision ?? framedHash('combo', [sourceBytes, sourceMap])
-  const entries = records.map(record => record.entry.id)
+    line += newlineCount(`${prepared.source};\n`)
+  }
+  return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
+}
+
+/** Describe one combo and defer its executable and debug payloads independently. */
+function buildCombo(
+  records: readonly WebPluginRecord[],
+  sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
+  revision?: string,
+): ComboArtifact {
+  const resources = records.map(record => ({
+    id: record.entry.id,
+    rev: record.entry.rev,
+    clientPath: record.meta.clientPath,
+    bundle: record.bundle,
+  }))
+  const rev = revision ?? comboRevision(resources)
+  const entries = resources.map(resource => resource.id)
   const url = comboUrl(entries, rev)
   const sourceMapUrl = comboUrl(entries, rev, true)
-  return { url, rev, entries, script: comboScript(source, sourceMapUrl), sourceMap, sourceMapUrl }
+  return {
+    url,
+    rev,
+    entries,
+    sourceMapUrl,
+    scriptBody: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
+    sourceMapBody: lazyBody(() => buildComboSourceMap(resources, sourceMapOf)),
+  }
 }
 
 /** Add initial-load scheduling metadata to a combo artifact. */
-function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
-  const artifact = buildCombo(records)
+function buildBatch(
+  phase: WebBootBatchPhase,
+  records: readonly WebPluginRecord[],
+  sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
+): BatchArtifact {
+  const artifact = buildCombo(records, sourceMapOf)
   return {
     ...artifact,
     descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
@@ -495,10 +555,10 @@ export class ClientModuleRegistry extends Service {
   private readonly dirty = new Set<string>()
   private readonly initialRevisionNonce = randomBytes(8).toString('hex')
   private nextInitialRevision = 0
-  private responses = new Map<string, { body: Buffer; contentType: string }>()
-  private batchResponses = new Map<string, { body: Buffer; contentType: string }>()
+  private responses = new Map<string, LazyResponse>()
+  private batchResponses = new Map<string, LazyResponse>()
   /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
-  private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
+  private previousBatchResponses = new Map<string, LazyResponse>()
   private flushQueued = false
   private composed: WebBootGraph
 
@@ -567,12 +627,13 @@ export class ClientModuleRegistry extends Service {
   /**
    * Serve an advertised revisioned bundle or source map without a Web server.
    * Unknown URLs return 404, unsupported methods return 405, and `HEAD`
-   * returns the same immutable headers without a body.
+   * returns the same immutable headers without materializing a body. Each body
+   * is built once on its first `GET`; script construction never reads maps.
    * @param request - shell-carrier request for a `/plugins` resource.
    * @returns the exact response also exposed by the optional Web route.
    */
-  fetchBundle(request: Request): Response {
-    const resource = this.bundleResource(request.method, request.url)
+  async fetchBundle(request: Request): Promise<Response> {
+    const resource = await this.bundleResource(request.method, request.url)
     const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
     return new Response(body, {
       status: resource.status,
@@ -604,14 +665,11 @@ export class ClientModuleRegistry extends Service {
     if (record === undefined) return undefined
     const baseline = this.captureArtifactBaseline(record.meta.clientPath)
     const bundle = readFileSync(record.meta.clientPath)
-    const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
-    const rev = artifactRevision(bundle, sourceMap)
+    const rev = artifactRevision(bundle)
     record.baseline = baseline
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
     record.bundle = bundle
-    if (sourceMap === undefined) delete record.sourceMap
-    else record.sourceMap = sourceMap
     this.composed = this.compose()
     for (const notify of this.rebuildListeners) {
       // Containment: rebuilt() runs inside the HMR watch callback — a
@@ -659,32 +717,32 @@ export class ClientModuleRegistry extends Service {
       .filter((record): record is WebPluginRecord => record !== undefined)
     const artifacts: BatchArtifact[] = []
     for (const records of partitionComboRecords(bootstrap)) {
-      artifacts.push(buildBatch('bootstrap', records))
+      artifacts.push(buildBatch('bootstrap', records, this.readSourceMap))
     }
     for (const records of partitionComboRecords(application)) {
-      artifacts.push(buildBatch('application', records))
+      artifacts.push(buildBatch('application', records, this.readSourceMap))
     }
 
-    const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
+    const batchResponses = new Map<string, LazyResponse>()
     for (const artifact of artifacts) {
-      batchResponses.set(artifact.descriptor.url, {
-        body: artifact.script,
+      batchResponses.set(artifact.descriptor.url, this.responses.get(artifact.descriptor.url) ?? {
+        body: artifact.scriptBody,
         contentType: 'text/javascript; charset=utf-8',
       })
-      batchResponses.set(artifact.sourceMapUrl, {
-        body: artifact.sourceMap,
+      batchResponses.set(artifact.sourceMapUrl, this.responses.get(artifact.sourceMapUrl) ?? {
+        body: artifact.sourceMapBody,
         contentType: 'application/json; charset=utf-8',
       })
     }
     const responses = new Map(batchResponses)
     for (const record of this.table.values()) {
-      const artifact = buildCombo([record], record.entry.rev)
-      responses.set(artifact.url, {
-        body: artifact.script,
+      const artifact = buildCombo([record], this.readSourceMap, record.entry.rev)
+      responses.set(artifact.url, responses.get(artifact.url) ?? this.responses.get(artifact.url) ?? {
+        body: artifact.scriptBody,
         contentType: 'text/javascript; charset=utf-8',
       })
-      responses.set(artifact.sourceMapUrl, {
-        body: artifact.sourceMap,
+      responses.set(artifact.sourceMapUrl, responses.get(artifact.sourceMapUrl) ?? this.responses.get(artifact.sourceMapUrl) ?? {
+        body: artifact.sourceMapBody,
         contentType: 'application/json; charset=utf-8',
       })
     }
@@ -838,7 +896,7 @@ export class ClientModuleRegistry extends Service {
   }
 
   /**
-   * Read the activation-time bundle and optional source-map snapshots.
+   * Read the activation-time bundle snapshot.
    * @param pkgName - package that declares the client bundle.
    * @param clientPath - absolute path of the built client artifact.
    * @returns the immutable bytes plus the pre-read filesystem baseline.
@@ -847,23 +905,21 @@ export class ClientModuleRegistry extends Service {
   private initialBundleSnapshot(pkgName: string, clientPath: string): {
     bundle: Buffer
     baseline: ClientArtifactBaseline
-    sourceMap?: WebPluginRecord['sourceMap']
   } {
     try {
       const baseline = this.captureArtifactBaseline(clientPath)
       const bundle = readFileSync(clientPath)
-      const sourceMap = this.readSourceMapSnapshot(clientPath)
-      return { bundle, baseline, ...(sourceMap === undefined ? {} : { sourceMap }) }
+      return { bundle, baseline }
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
       throw new MissingClientBundleError(pkgName, clientPath, error)
     }
   }
 
-  /** Treat a missing, torn, or malformed development map as an identity-mapped artifact revision. */
-  private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
+  /** Treat a missing, torn, or malformed development map as an identity section. */
+  private readonly readSourceMap = (clientPath: string): Record<string, unknown> | undefined => {
     try {
-      return sourceMapSnapshot(clientPath)
+      return readSourceMap(clientPath)
     } catch (error) {
       this.ctx.logger.warn(error)
       return undefined
@@ -938,7 +994,6 @@ export class ClientModuleRegistry extends Service {
       meta: source.meta,
       bundle: snapshot.bundle,
       baseline: snapshot.baseline,
-      ...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
     })
     return true
   }
@@ -971,11 +1026,11 @@ export class ClientModuleRegistry extends Service {
     this.notifyGraphChanged()
   }
 
-  private bundleResource(method: string | undefined, url: string): {
+  private async bundleResource(method: string | undefined, url: string): Promise<{
     status: number
     headers?: Record<string, string>
     body?: Buffer
-  } {
+  }> {
     if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
     const requestUrl = new URL(url, 'http://x')
     const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
@@ -984,7 +1039,7 @@ export class ClientModuleRegistry extends Service {
       return {
         status: 200,
         headers: { 'content-type': response.contentType, 'cache-control': IMMUTABLE_CACHE },
-        ...(method === 'HEAD' ? {} : { body: response.body }),
+        ...(method === 'HEAD' ? {} : { body: await response.body() }),
       }
     }
     // Anything else under /plugins (including unadvertised combinations and
@@ -992,9 +1047,9 @@ export class ClientModuleRegistry extends Service {
     return { status: 404 }
   }
 
-  private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => {
+  private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
     /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
-    const response = this.bundleResource(req.method, req.url ?? '/')
+    const response = await this.bundleResource(req.method, req.url ?? '/')
     res.writeHead(response.status, response.headers)
     res.end(response.body)
   }

+ 82 - 3
packages/client/modules/tests/node-half.client.spec.ts

@@ -52,7 +52,7 @@ it.each([false, true])('tracks the Web carrier lifetime when server-first is %s'
   const modules = await ctx.plugin(ClientModuleRegistry)
   const service = ctx.get('clientModules')!
   expect(service.graph().entries).toEqual([])
-  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
   if (!serverFirst) {
     expect(routes.size).toBe(0)
     server = await mountServer()
@@ -61,7 +61,7 @@ it.each([false, true])('tracks the Web carrier lifetime when server-first is %s'
   await server!.dispose()
   await expect.poll(() => routes.size).toBe(0)
   expect(modules.state).toBe(FiberState.ACTIVE)
-  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
   await mountServer()
   await expect.poll(() => routes.size).toBe(1)
   await modules.dispose()
@@ -507,6 +507,85 @@ describe('client bundle activation', () => {
 
     writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
     expect(() => construct([packageName])).not.toThrow()
+
+    writeFileSync(`${clientPath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sourceRoot: 'http://[',
+      sources: ['src/index.ts'],
+    }))
+    const invalidUrl = constructWithRoute([packageName])
+    const invalidMapUrl = mapUrl(invalidUrl.service.graph().batches[0]!.url)
+    expect(JSON.parse((await routeRequest(invalidUrl.route, invalidMapUrl)).body.toString('utf8'))).toMatchObject({
+      sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
+    })
+  })
+
+  it('reads a source map only on its first map GET', async () => {
+    const packageName = '@fixture/lazy-source-map'
+    const clientPath = writePackage(packageName)
+    mkdirSync(dirname(clientPath), { recursive: true })
+    writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
+    writeFileSync(`${clientPath}.map`, '{')
+    const { service, route } = constructWithRoute([packageName])
+    const batch = service.graph().batches[0]!
+    const sourceMapUrl = mapUrl(batch.url)
+
+    const script = await routeRequest(route, batch.url)
+    expect(script.status).toBe(200)
+    expect((await routeRequest(route, sourceMapUrl, 'HEAD')).body).toHaveLength(0)
+    writeFileSync(`${clientPath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sources: ['src/first.ts'],
+    }))
+    const first = await routeRequest(route, sourceMapUrl)
+    expect(JSON.parse(first.body.toString('utf8'))).toMatchObject({
+      sections: [{ map: { sources: [`/plugins/${packageName}/src/first.ts`] } }],
+    })
+
+    writeFileSync(`${clientPath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sources: ['src/second.ts'],
+    }))
+    expect((await routeRequest(route, sourceMapUrl)).body).toEqual(first.body)
+    expect((await routeRequest(route, batch.url)).body).toEqual(script.body)
+  })
+
+  it('retains a materialized resource when an unrelated row recomposes the graph', async () => {
+    const stablePackage = '@fixture/stable-source-map'
+    const rebuiltPackage = '@fixture/rebuilt-neighbor'
+    const stablePath = writePackage(stablePackage)
+    const rebuiltPath = writePackage(rebuiltPackage)
+    for (const clientPath of [stablePath, rebuiltPath]) {
+      mkdirSync(dirname(clientPath), { recursive: true })
+      writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
+      writeFileSync(`${clientPath}.map`, JSON.stringify({
+        version: 3,
+        names: [],
+        mappings: 'AAAA',
+        sources: ['src/first.ts'],
+      }))
+    }
+    const { service, route } = constructWithRoute([stablePackage, rebuiltPackage])
+    const stableUrl = service.graph().entries.find(entry => entry.id === stablePackage)!.url
+    const stableMapUrl = mapUrl(stableUrl)
+    const first = await routeRequest(route, stableMapUrl)
+
+    writeFileSync(`${stablePath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sources: ['src/second.ts'],
+    }))
+    writeFileSync(rebuiltPath, 'module.exports = { rebuilt: true }\n')
+    service.rebuilt(rebuiltPackage)
+
+    expect((await routeRequest(route, stableMapUrl)).body).toEqual(first.body)
   })
 
   it('maps packed combo sections back to each generated client bundle', async () => {
@@ -668,7 +747,7 @@ describe('client bundle activation', () => {
     expect(batchScript.status).toBe(200)
     expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
     expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
-    const shellResponse = service.fetchBundle(new Request(`dsh-app://app${batch.url}`))
+    const shellResponse = await service.fetchBundle(new Request(`dsh-app://app${batch.url}`))
     expect(shellResponse.status).toBe(200)
     expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
     expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8'))

+ 2 - 2
packages/client/ui-theme/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-theme/README.md
-README.md: 291492b9b41df6570722fb2dbd14565c660ec350
-README.zh.md: 08cef7aca2f6df648fe75b3d27aed93c44a2d30c
+README.md: a25f0202d278d52448d52a22f451612295325f87
+README.zh.md: 08a0c37dc3c80b4385173e6ca74bb9254b4870ea

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

@@ -37,7 +37,7 @@ A composition can register a third-party theme id with alias-token overrides thr
 
 ### Pre-plugin palette
 
-When the host composition includes an HTTP server, the host half embeds the registered `ui-theme` settings, or schema defaults, into each index response. Before the loading page renders, the browser sets `color-scheme`, `body[data-ds-dark-theme]`, and `--dsh-content-font-size`, so the first paint uses the selected palette and text size.
+When the host composition includes an HTTP server, the host half embeds the registered `ui-theme` settings, or schema defaults, into each index response. Head CSS selects the document canvas color scheme before any script runs, including a `prefers-color-scheme` query for the `system` preference. A body script then sets `body[data-ds-dark-theme]` and `--dsh-content-font-size` before the loading page and application scripts, so the first paint uses the selected palette and text size.
 
 -----
 

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

@@ -37,7 +37,7 @@ kind: "package-reference"
 
 ### 插件前调色板
 
-当主机组合包含 HTTP 服务器时,宿主侧会把已注册的 `ui-theme` 设置或 schema 默认值嵌入每份 index 响应。浏览器在加载页面渲染前设置 `color-scheme`、`body[data-ds-dark-theme]` 与 `--dsh-content-font-size`,因此首帧绘制就采用所选调色板与字号。
+当主机组合包含 HTTP 服务器时,宿主侧会把已注册的 `ui-theme` 设置或 schema 默认值嵌入每份 index 响应。head CSS 会在任何脚本运行前选择文档画布的配色方案,其中 `system` 偏好使用 `prefers-color-scheme` 查询;随后,body 脚本会在加载页面和应用脚本之前设置 `body[data-ds-dark-theme]` 与 `--dsh-content-font-size`,因此首帧绘制就采用所选调色板与字号。
 
 -----
 

+ 27 - 12
packages/client/ui-theme/src/boot-theme.ts

@@ -1,37 +1,52 @@
 /**
  * Theme bootstrap row for the browser's pre-plugin interval. Each index
- * render embeds the current durable built-in preference and content font size;
- * the browser resolves only `system`, then writes the same DOM fields
- * ui-layout's ThemePresenter owns after the client plugin tree activates.
+ * render embeds the current durable built-in preference and content font size.
+ * Head CSS colors the document canvas before script execution; the body script
+ * installs the palette selector and font size that the client presenters adopt.
  */
 
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import { DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
 
-/** Build the inline script body for one schema-validated durable theme section. */
-function bootThemeScript(preference: ThemePreference, fontSize: number): string {
+const LIGHT_BACKGROUND = '#fff'
+const DARK_BACKGROUND = '#151517'
+
+/** CSS that colors the document canvas before any script executes. */
+function bootThemeStyle(preference: ThemePreference): string {
+  const light = `:root{color-scheme:light}body{background-color:${LIGHT_BACKGROUND};--dsh-boot-bg:${LIGHT_BACKGROUND}}`
+  const dark = `:root{color-scheme:dark}body{background-color:${DARK_BACKGROUND};--dsh-boot-bg:${DARK_BACKGROUND}}`
+  if (preference === 'light') return light
+  if (preference === 'dark') return dark
+  return `${light}@media(prefers-color-scheme:dark){${dark}}`
+}
+
+/** Build the body script that installs the palette selector and content size. */
+function bootThemeBodyScript(preference: ThemePreference, fontSize: number): string {
   return `(() => {
   const preference = ${JSON.stringify(preference)}
   const systemDark = preference === 'system'
     && typeof matchMedia !== 'undefined'
     && matchMedia('(prefers-color-scheme: dark)').matches
   const dark = preference === 'dark' || systemDark
-  document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
   document.body.toggleAttribute('data-ds-dark-theme', dark)
   document.body.style.setProperty('--dsh-content-font-size', ${JSON.stringify(`${fontSize}px`)})
 })()`
 }
 
 /**
- * The theme bootstrap as an injection row: an inline script immediately after
- * the opening body tag, before the shell mount and module script.
+ * Theme bootstrap rows: head CSS colors the document canvas before
+ * first paint, then the body script installs the palette selector and font
+ * size before the shell mount and module script.
  * @param preference - Current Host-backed built-in preference.
  * @param fontSize - Current Host-backed content font size in px.
- * @returns the body script row.
+ * @returns head and body script rows in execution order.
  */
-export function bootThemeInjection(
+export function bootThemeInjections(
   preference: ThemePreference = DEFAULT_PREFERENCE,
   fontSize: number = DEFAULT_FONT_SIZE,
-): IndexInjection {
-  return { kind: 'script', placement: 'body', text: bootThemeScript(preference, fontSize) }
+): IndexInjection[] {
+  return [
+    { kind: 'style', text: bootThemeStyle(preference) },
+    { kind: 'script', placement: 'body', text: bootThemeBodyScript(preference, fontSize) },
+  ]
 }

+ 3 - 3
packages/client/ui-theme/src/index.ts

@@ -3,7 +3,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import type {} from '@deepseek-ai/dsh-settings'
-import { bootThemeInjection } from './boot-theme.ts'
+import { bootThemeInjections } from './boot-theme.ts'
 import {
   DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
   type ThemePreference, type ThemeSettings,
@@ -39,6 +39,6 @@ export function apply(ctx: Context): void {
   })
   ctx.on('webserver/index-inject', (table) => {
     const section = readSection(ctx)
-    table.push(bootThemeInjection(section.preference, section.fontSize))
-  })
+    table.push(...bootThemeInjections(section.preference, section.fontSize))
+  }, { prepend: true })
 }

+ 2 - 0
packages/client/ui-theme/src/styles/design-platform.css

@@ -244,6 +244,8 @@ body {
   --dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-100);
   --dsw-specific-sidebar-nav-item-hover: var(--dsw-static-neutral-bluish-75);
   --dsw-specific-tip: var(--dsw-static-neutral-bluish-60);
+
+  background-color: var(--dsw-alias-bg-base);
 }
 
 body[data-ds-dark-theme] {

+ 28 - 16
packages/client/ui-theme/tests/boot-theme.client.spec.ts

@@ -2,7 +2,7 @@
 /** The theme bootstrap injection row and the resulting pre-plugin browser theme. */
 import { runInNewContext } from 'node:vm'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { bootThemeInjection } from '../src/boot-theme.ts'
+import { bootThemeInjections } from '../src/boot-theme.ts'
 import type { ThemePreference } from '../src/theme-settings.ts'
 
 const DARK_ATTRIBUTE = 'data-ds-dark-theme'
@@ -12,51 +12,63 @@ function mockSystemDark(matches: boolean): void {
 }
 
 function executeBootstrap(preference?: ThemePreference, fontSize?: number): void {
-  const row = bootThemeInjection(preference, fontSize)
-  if (row.kind !== 'script') throw new Error('theme bootstrap row is not a script')
-  runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia })
+  for (const row of bootThemeInjections(preference, fontSize)) {
+    if (row.kind === 'script') runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia })
+  }
 }
 
 afterEach(() => {
   vi.restoreAllMocks()
   vi.unstubAllGlobals()
-  document.documentElement.style.removeProperty('color-scheme')
   document.body.removeAttribute(DARK_ATTRIBUTE)
   document.body.style.removeProperty('--dsh-content-font-size')
 })
 
 describe('theme bootstrap row', () => {
-  it('is a body script row, so it runs before the shell mount', () => {
+  it('colors the body with head CSS before applying body state', () => {
     mockSystemDark(false)
-    const row = bootThemeInjection('dark')
-    expect(row).toMatchObject({ kind: 'script', placement: 'body' })
-    executeBootstrap('dark')
-    expect(document.documentElement.style.colorScheme).toBe('dark')
+    const [head, body] = bootThemeInjections('dark')
+    expect(head).toMatchObject({ kind: 'style' })
+    expect(body).toMatchObject({ kind: 'script', placement: 'body' })
+    if (head?.kind !== 'style') throw new Error('theme head bootstrap row is not a style')
+    expect(head.text).toBe(':root{color-scheme:dark}body{background-color:#151517;--dsh-boot-bg:#151517}')
+    expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
+    if (body?.kind !== 'script') throw new Error('theme body bootstrap row is not a script')
+    runInNewContext(body.text, { document, matchMedia: globalThis.matchMedia })
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
   })
 
   it('lets durable light override a dark OS and clears stale dark state', () => {
     document.body.setAttribute(DARK_ATTRIBUTE, '')
     mockSystemDark(true)
+    const [head] = bootThemeInjections('light')
+    if (head?.kind !== 'style') throw new Error('theme head bootstrap row is not a style')
+    expect(head.text).toBe(':root{color-scheme:light}body{background-color:#fff;--dsh-boot-bg:#fff}')
     executeBootstrap('light')
-    expect(document.documentElement.style.colorScheme).toBe('light')
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
   })
 
   it.each([
-    [true, 'dark', true],
-    [false, 'light', false],
-  ] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
+    [true, true],
+    [false, false],
+  ] as const)('resolves system=%s for the body palette', (matches, dark) => {
     mockSystemDark(matches)
     executeBootstrap('system')
-    expect(document.documentElement.style.colorScheme).toBe(colorScheme)
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
   })
 
+  it('uses a media query for the system canvas palette', () => {
+    const [head] = bootThemeInjections('system')
+    if (head?.kind !== 'style') throw new Error('theme head bootstrap row is not a style')
+    expect(head.text).toBe(
+      ':root{color-scheme:light}body{background-color:#fff;--dsh-boot-bg:#fff}'
+      + '@media(prefers-color-scheme:dark){:root{color-scheme:dark}body{background-color:#151517;--dsh-boot-bg:#151517}}',
+    )
+  })
+
   it('defaults to system and falls back to light when matchMedia is unavailable', () => {
     vi.stubGlobal('matchMedia', undefined)
     executeBootstrap()
-    expect(document.documentElement.style.colorScheme).toBe('light')
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
   })
 

+ 19 - 12
packages/client/ui-theme/tests/host.client.spec.ts

@@ -21,9 +21,9 @@ function collect(ctx: Context): IndexInjection[] {
   return table
 }
 
-/** Narrow the theme row and return its script body. */
-function scriptText(row: IndexInjection | undefined): string {
-  if (row?.kind !== 'script') throw new Error('expected a script row')
+/** Narrow a theme style or script row and return its text. */
+function rowText(row: IndexInjection | undefined): string {
+  if (row?.kind !== 'script' && row?.kind !== 'style') throw new Error('expected a style or script row')
   return row.text
 }
 
@@ -47,24 +47,31 @@ describe('ui-theme host', () => {
   it('answers each collection with the current durable preference until disposal', async () => {
     const ctx = new Context()
     await ctx.plugin(MemorySettings).await()
+    ctx.on('webserver/index-inject', (table) => {
+      table.push({ kind: 'script', placement: 'head', text: 'window.afterTheme=true' })
+    })
     const fiber = ctx.plugin({ apply })
     await fiber.await()
     const rows = collect(ctx)
-    expect(rows).toHaveLength(1)
-    expect(rows[0]).toMatchObject({ kind: 'script', placement: 'body' })
-    expect(scriptText(rows[0])).toContain('const preference = "system"')
-    expect(scriptText(rows[0])).toContain('"14px"')
+    expect(rows).toHaveLength(3)
+    expect(rows[0]).toMatchObject({ kind: 'style' })
+    expect(rows[1]).toMatchObject({ kind: 'script', placement: 'body' })
+    expect(rows[2]).toMatchObject({ kind: 'script', placement: 'head', text: 'window.afterTheme=true' })
+    expect(rowText(rows[0])).toContain('@media(prefers-color-scheme:dark)')
+    expect(rowText(rows[1])).toContain('const preference = "system"')
+    expect(rowText(rows[1])).toContain('"14px"')
     await ctx.settings.update(THEME_SETTINGS_NAMESPACE, { preference: 'dark', fontSize: 17 })
-    expect(scriptText(collect(ctx)[0])).toContain('const preference = "dark"')
-    expect(scriptText(collect(ctx)[0])).toContain('"17px"')
+    expect(rowText(collect(ctx)[0])).toContain('color-scheme:dark')
+    expect(rowText(collect(ctx)[1])).toContain('const preference = "dark"')
+    expect(rowText(collect(ctx)[1])).toContain('"17px"')
     await fiber.dispose()
-    expect(collect(ctx)).toEqual([])
+    expect(collect(ctx)).toEqual([{ kind: 'script', placement: 'head', text: 'window.afterTheme=true' }])
   })
 
   it('uses the system preference without a settings provider', async () => {
     const ctx = new Context()
     await ctx.plugin({ apply }).await()
-    expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
+    expect(rowText(collect(ctx)[1])).toContain('const preference = "system"')
   })
 
   it('falls back to the schema default while the theme namespace holds no section', async () => {
@@ -73,6 +80,6 @@ describe('ui-theme host', () => {
     const ctx = new Context()
     ctx.provide('settings', { register: () => () => {}, get: () => undefined } as never)
     await ctx.plugin({ apply }).await()
-    expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
+    expect(rowText(collect(ctx)[1])).toContain('const preference = "system"')
   })
 })

+ 1 - 3
packages/client/web/src/boot-page.module.css

@@ -1,7 +1,6 @@
 /* The framework-free boot page cannot depend on theme delivery succeeding. */
 
 .boot {
-  --dsh-boot-bg: #fff;
   --dsh-boot-label-primary: #0f1115;
   --dsh-boot-label-secondary: #61666b;
   --dsh-boot-label-tertiary: #81858c;
@@ -11,11 +10,10 @@
   height: 100%;
   display: grid;
   place-items: center;
-  background: var(--dsw-alias-bg-base, var(--dsh-boot-bg));
+  background: var(--dsw-alias-bg-base, var(--dsh-boot-bg, Canvas));
 }
 
 :global(body[data-ds-dark-theme]) .boot {
-  --dsh-boot-bg: #151517;
   --dsh-boot-label-primary: #f9fafb;
   --dsh-boot-label-secondary: #cfd3d6;
   --dsh-boot-label-tertiary: #adb2b8;

+ 1 - 5
packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts

@@ -29,10 +29,7 @@ const DOCKKIT_BUNDLE = 'packages/client/ui-dockkit/lib/index.js'
  * Files Node's ESM loader cannot import in this repository. None is a finding:
  * each is listed with the reason the import fails, and the run refuses a
  * listed file that imports cleanly so the list stays current in both
- * directions. The koffi entry depends on corpus order: sandbox-windows-acl
- * imports the win32-process package earlier in the serial sweep (a distinct
- * module instance under its node_modules URL), so win32-process's own file-URL
- * import re-registers koffi's type names and fails as the second load.
+ * directions.
  *
  * The Dockkit entry admits only Node's refusal of a `.css` import, not one
  * exact stylesheet: the swept bundle's first unresolvable stylesheet depends on
@@ -43,7 +40,6 @@ const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
   [DOCKKIT_BUNDLE, 'imports .css, which bare Node cannot load'],
   ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'],
-  ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'],
   ['packages/test-support/client-runtime/lib/index.js', "needs vitest's internal state"],
 ])
 

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

@@ -625,8 +625,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the path, or undefined for an unknown id.',
       },
       {
-        signature: 'fetchBundle(request: Request): Response',
-        description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without a body.',
+        signature: 'async fetchBundle(request: Request): Promise<Response>',
+        description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without materializing a body. Each body is built once on its first `GET`; script construction never reads maps.',
         parameters: [{ name: 'request', description: 'shell-carrier request for a `/plugins` resource.' }],
         returns: 'the exact response also exposed by the optional Web route.',
       },
@@ -2807,20 +2807,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         signature: 'get(key: string): TypertSchemaRecord | undefined',
         description: 'Look up one schema by `<package>#<name>`.',
         parameters: [{ name: 'key', description: 'global schema key.' }],
-        returns: 'the live schema record, or `undefined` when absent.',
+        returns: 'a record containing the cached schema, or `undefined` when absent.',
       },
       {
         signature: 'resolve(key: string): TypertSchemaRecord',
         description: 'Resolve one required schema.',
         parameters: [{ name: 'key', description: 'global schema key.' }],
-        returns: 'the live schema record.',
+        returns: 'a record containing the cached schema.',
         throws: ['when the key is malformed, the package face is absent, or the schema is not contributed.'],
       },
       {
         signature: 'list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]',
         description: 'Enumerate live schemas in registration order.',
         parameters: [{ name: 'filter', description: 'optional package and face restriction.' }],
-        returns: 'matching schema records.',
+        returns: 'matching records containing the cached schemas.',
       },
       {
         signature: 'getPackage(packageName: string, face: TypertFace = \'host\'): TypertPackageRecord | undefined',
@@ -6356,11 +6356,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'TypertCodec',
-    declaration: 'export type TypertCodec = {\n    readonly mode: \'strict\';\n    readonly typeSymbol: string;\n    readonly schema: TypertSchema;\n} | {\n    readonly mode: \'src-json\';\n};',
+    declaration: 'export type TypertCodec = {\n    readonly mode: \'strict\';\n    readonly typeSymbol: string;\n    readonly create: () => TypertSchema;\n} | {\n    readonly mode: \'src-json\';\n};',
   },
   {
     name: 'TypertContribution',
-    declaration: 'export interface TypertContribution {\n    readonly package: string;\n    readonly face: TypertFace;\n    readonly schemas: readonly TypertSchema[];\n    readonly model: TypertPackageModel;\n    readonly invocations: readonly InvocationDescriptor[];\n}',
+    declaration: 'export interface TypertContribution {\n    readonly package: string;\n    readonly face: TypertFace;\n    readonly schemas: readonly TypertSchemaFactory[];\n    readonly model: TypertPackageModel;\n    readonly invocations: readonly InvocationDescriptor[];\n}',
   },
   {
     name: 'TypertDisposer',
@@ -6434,13 +6434,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'TypertRemoteService',
     declaration: 'export abstract class TypertRemoteService<out T = never> extends Service<T> {\n    readonly typertRemote: TypertGatewayBinding<this>;\n}',
   },
+  {
+    name: 'TypertSchema',
+    declaration: 'export interface TypertSchema<Output = unknown> {\n    parse(value: unknown): Output;\n}',
+  },
+  {
+    name: 'TypertSchemaFactory',
+    declaration: 'export interface TypertSchemaFactory {\n    readonly name: string;\n    readonly create: () => z.ZodType;\n}',
+  },
   {
     name: 'TypertSchemaFilter',
     declaration: 'export interface TypertSchemaFilter {\n    readonly package?: string;\n    readonly face?: TypertFace;\n}',
   },
   {
     name: 'TypertSchemaRecord',
-    declaration: 'export interface TypertSchemaRecord extends TypertSchema {\n    readonly package: string;\n    readonly face: TypertFace;\n    readonly key: string;\n}',
+    declaration: 'export interface TypertSchemaRecord {\n    readonly name: string;\n    readonly schema: z.ZodType;\n    readonly package: string;\n    readonly face: TypertFace;\n    readonly key: string;\n}',
   },
   {
     name: 'TypertServiceModel',

+ 1 - 0
packages/sandbox/sandbox-windows-acl/package.json

@@ -37,6 +37,7 @@
     "@deepseek-ai/dsh-subprocess": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/dsh-win32-process": "workspace:^",
     "koffi": "^3.1.0"
   },

+ 22 - 11
packages/sandbox/sandbox-windows-acl/src/ffi.ts

@@ -1,6 +1,6 @@
 /** ACL/token bindings layered on the shared Win32 process owner. */
 
-import koffi from 'koffi'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import {
   ERROR_INSUFFICIENT_BUFFER,
   Win32Error,
@@ -22,9 +22,18 @@ export {
 } from '@deepseek-ai/dsh-win32-process'
 export type { NativePtr } from '@deepseek-ai/dsh-win32-process'
 
-type Ptr = ReturnType<typeof koffi.pointer>
-const PVOID: Ptr = koffi.pointer('void')
-const PPVOID: Ptr = koffi.pointer(PVOID)
+type Koffi = typeof import('koffi')['default']
+type Ptr = ReturnType<Koffi['pointer']>
+
+const requireKoffi = createLazyRequire<Koffi>('koffi', import.meta.url)
+let cachedTypes: { PVOID: Ptr; PPVOID: Ptr } | undefined
+
+function ffiTypes(): { PVOID: Ptr; PPVOID: Ptr } {
+  if (cachedTypes !== undefined) return cachedTypes
+  const koffi = requireKoffi()
+  const PVOID = koffi.pointer('void')
+  return cachedTypes = { PVOID, PPVOID: koffi.pointer(PVOID) }
+}
 
 /** ACL/token calls composed with the generic Win32 process binding table. */
 export interface Win32Bindings extends Win32ProcessBindings {
@@ -115,7 +124,7 @@ export function isInvalidHandle(handle: NativePtr | null | undefined): boolean {
  * @param value - unsigned value to store.
  */
 export function encodeUint32(slot: NativePtr, value: number): void {
-  koffi.encode(slot, 'uint32', value)
+  requireKoffi().encode(slot, 'uint32', value)
 }
 
 /**
@@ -124,7 +133,7 @@ export function encodeUint32(slot: NativePtr, value: number): void {
  * @returns pointer address.
  */
 export function ptrAddress(ptr: NativePtr): bigint {
-  return koffi.address(ptr)
+  return requireKoffi().address(ptr)
 }
 
 /**
@@ -133,7 +142,7 @@ export function ptrAddress(ptr: NativePtr): bigint {
  * @returns allocated pointer.
  */
 export function allocBytes(length: number): NativePtr {
-  return koffi.alloc('uint8', length) as NativePtr
+  return requireKoffi().alloc('uint8', length) as NativePtr
 }
 
 /**
@@ -153,7 +162,7 @@ export function allocOverlapped(): NativePtr {
  * @returns decoded pointer, or null for address zero.
  */
 export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null {
-  const value = koffi.decode(buffer, offset, PVOID) as NativePtr | null
+  const value = requireKoffi().decode(buffer, offset, ffiTypes().PVOID) as NativePtr | null
   return isNullPtr(value) ? null : value
 }
 
@@ -164,7 +173,7 @@ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null {
  * @returns decoded value.
  */
 export function decodeUint8At(ptr: NativePtr, offset: number): number {
-  return koffi.decode(ptr, offset, 'uint8') as number
+  return requireKoffi().decode(ptr, offset, 'uint8') as number
 }
 
 /**
@@ -174,7 +183,7 @@ export function decodeUint8At(ptr: NativePtr, offset: number): number {
  * @returns decoded value.
  */
 export function decodeUint16At(ptr: NativePtr, offset: number): number {
-  return koffi.decode(ptr, offset, 'uint16') as number
+  return requireKoffi().decode(ptr, offset, 'uint16') as number
 }
 
 /**
@@ -184,7 +193,7 @@ export function decodeUint16At(ptr: NativePtr, offset: number): number {
  * @returns decoded value.
  */
 export function decodeUint32At(ptr: NativePtr, offset: number): number {
-  return koffi.decode(ptr, offset, 'uint32') as number
+  return requireKoffi().decode(ptr, offset, 'uint32') as number
 }
 
 /**
@@ -221,6 +230,8 @@ let cached: Win32Bindings | undefined
 
 function bindings(): Win32Bindings {
   if (cached !== undefined) return cached
+  const koffi = requireKoffi()
+  const { PVOID, PPVOID } = ffiTypes()
   cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({
     openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
     openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]),

+ 2 - 2
packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts

@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import { Win32Error } from '@deepseek-ai/dsh-win32-process'
 import { ERROR_BROKEN_PIPE } from '@deepseek-ai/dsh-win32-process/src/abi.ts'
-import { PROCESS_INFORMATION } from '@deepseek-ai/dsh-win32-process/src/ffi.ts'
+import { processInformationType } from '@deepseek-ai/dsh-win32-process/src/ffi.ts'
 import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
 import koffi from 'koffi'
 
@@ -133,7 +133,7 @@ function happyStubs(): HappyStubs {
     _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
     _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
   ) => {
-    koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
+    koffi.encode(processInfo, processInformationType(), { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
     return 1
   })
   const peekNamedPipe = vi.fn(() => 0)

+ 3 - 0
packages/sandbox/sandbox-windows-acl/tsconfig.json

@@ -22,6 +22,9 @@
     },
     {
       "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../util/lazy-require"
     }
   ]
 }

+ 1 - 0
packages/subprocess/subprocess-local/package.json

@@ -47,6 +47,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/dsh-win32-process": "workspace:^",
     "koffi": "^3.1.0",
     "node-pty": "1.2.0-beta.15"

+ 6 - 3
packages/subprocess/subprocess-local/src/index.ts

@@ -14,8 +14,9 @@ import { userInfo } from 'node:os'
 import { delimiter, extname, isAbsolute, resolve } from 'node:path'
 import type { Duplex } from 'node:stream'
 import { Context } from '@deepseek-ai/cordis'
-import * as nodePty from 'node-pty'
+import type * as NodePty from 'node-pty'
 import type { IPtyForkOptions } from 'node-pty'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
 import type {
   SubprocessHandle,
@@ -45,6 +46,8 @@ import { createProcessInspector } from './process-inspector.ts'
 import type { ProcessInspector } from './process-inspector.ts'
 import { LocalTerminalHandle } from './terminal.ts'
 
+const requireNodePty = createLazyRequire<typeof NodePty>('node-pty', import.meta.url)
+
 /**
  * Local subprocess service: platform-selected managed ranges, Node-shaped stdio
  * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
@@ -280,9 +283,9 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
       options.cwd = scope.cwd
       options.env = scope.env
     }
-    let terminal: nodePty.IPty
+    let terminal: NodePty.IPty
     try {
-      terminal = nodePty.spawn(
+      terminal = requireNodePty().spawn(
         scope?.command ?? file,
         scope?.args ?? [...spec.argv.slice(1)],
         options,

+ 4 - 1
packages/subprocess/subprocess-local/src/linux-execve.ts

@@ -1,8 +1,10 @@
 /** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */
 
 import { getSystemErrorMessage, getSystemErrorName } from 'node:util'
-import koffi from 'koffi'
 import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
+
+const requireKoffi = createLazyRequire<typeof import('koffi')['default']>('koffi', import.meta.url)
 
 /** Replace the current process image while preserving the supplied argv and environment. */
 export type LinuxExecve = (
@@ -46,6 +48,7 @@ function systemError(errno: number, syscall: string, path?: string): Error {
  */
 export function loadLinuxExecve(): LinuxExecve {
   if (cachedExecve !== undefined) return cachedExecve
+  const koffi = requireKoffi()
   const libc = koffi.load(null)
   const nativeExecve = libc.func(
     'int execve(const char *pathname, const char **argv, const char **envp)',

+ 16 - 7
packages/subprocess/subprocess-local/src/windows-inspector.ts

@@ -10,10 +10,15 @@
  */
 
 import { spawnSync } from 'node:child_process'
-import koffi from 'koffi'
 import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
 
+type Koffi = typeof import('koffi')['default']
+type KoffiType = ReturnType<Koffi['pointer']>
+
+const requireKoffi = createLazyRequire<Koffi>('koffi', import.meta.url)
+
 /** One Toolhelp32 process-table row. */
 export interface ProcessEntry {
   pid: number
@@ -183,16 +188,16 @@ interface Win32Bindings {
   closeHandle(handle: NativePtr): number
 }
 
-const PVOID: ReturnType<typeof koffi.pointer> = koffi.pointer('void')
-
 /**
  * Resolve the koffi Win32 struct types once. Registration is lazy and cached
  * because koffi's type registry is global per process: test runners that
  * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
  * re-register the names.
  */
-function win32Structs(): { PROCESSENTRY32W: ReturnType<typeof koffi.struct>; FILETIME: ReturnType<typeof koffi.struct> } {
+function win32Structs(): { PVOID: KoffiType; PROCESSENTRY32W: ReturnType<Koffi['struct']>; FILETIME: ReturnType<Koffi['struct']> } {
   if (cachedStructs !== undefined) return cachedStructs
+  const koffi = requireKoffi()
+  const PVOID = koffi.pointer('void')
   // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout.
   const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', {
     dwSize: 'uint32',
@@ -216,7 +221,7 @@ function win32Structs(): { PROCESSENTRY32W: ReturnType<typeof koffi.struct>; FIL
     throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`)
   }
   /* v8 ignore stop */
-  cachedStructs = { PROCESSENTRY32W, FILETIME }
+  cachedStructs = { PVOID, PROCESSENTRY32W, FILETIME }
   return cachedStructs
 }
 
@@ -236,7 +241,8 @@ let cachedBindings: Win32Bindings | undefined
  */
 function win32Bindings(): Win32Bindings {
   if (cachedBindings !== undefined) return cachedBindings
-  const { PROCESSENTRY32W, FILETIME } = win32Structs()
+  const koffi = requireKoffi()
+  const { PVOID, PROCESSENTRY32W, FILETIME } = win32Structs()
   const kernel32 = koffi.load('kernel32.dll')
   const bind = (
     name: string,
@@ -268,13 +274,15 @@ function win32Bindings(): Win32Bindings {
  * @param count - element count.
  * @returns the branded allocation pointer.
  */
-function allocNative(type: Parameters<typeof koffi.alloc>[0], count: number): NativePtr {
+function allocNative(type: Parameters<Koffi['alloc']>[0], count: number): NativePtr {
+  const koffi = requireKoffi()
   const value: unknown = koffi.alloc(type, count)
   return value as NativePtr
 }
 
 /** Enumerate the current process table through Toolhelp32. */
 function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
+  const koffi = requireKoffi()
   const { PROCESSENTRY32W } = win32Structs()
   const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
   /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
@@ -301,6 +309,7 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
 
 /** Read one process's creation identity and current wait state. */
 function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined {
+  const koffi = requireKoffi()
   const { FILETIME } = win32Structs()
   const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid)
   if (isInvalidHandle(handle)) return undefined

+ 10 - 4
packages/subprocess/subprocess-local/tests/linux-execve.spec.ts

@@ -1,7 +1,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 
 afterEach(() => {
-  vi.doUnmock('koffi')
+  vi.doUnmock('@deepseek-ai/dsh-lazy-require')
   vi.resetModules()
 })
 
@@ -17,7 +17,9 @@ describe.skipIf(process.platform !== 'linux')('Linux libc execve binding', () =>
       : nativeFcntl)
     const load = vi.fn(() => ({ func }))
     const errno = vi.fn(() => 2)
-    vi.doMock('koffi', () => ({ default: { errno, load } }))
+    vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({
+      createLazyRequire: () => () => ({ errno, load }),
+    }))
 
     const { loadLinuxExecve } = await import('../src/linux-execve.ts')
     const execve = loadLinuxExecve()
@@ -65,7 +67,9 @@ describe.skipIf(process.platform !== 'linux')('Linux libc execve binding', () =>
       ? nativeExecve
       : nativeFcntl)
     const errno = vi.fn(() => 9)
-    vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } }))
+    vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({
+      createLazyRequire: () => () => ({ errno, load: () => ({ func }) }),
+    }))
 
     const { loadLinuxExecve } = await import('../src/linux-execve.ts')
     expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({
@@ -87,7 +91,9 @@ describe.skipIf(process.platform !== 'linux')('Linux libc execve binding', () =>
       ? nativeExecve
       : nativeFcntl)
     const errno = vi.fn(() => 5)
-    vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } }))
+    vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({
+      createLazyRequire: () => () => ({ errno, load: () => ({ func }) }),
+    }))
 
     const { loadLinuxExecve } = await import('../src/linux-execve.ts')
     expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({

+ 21 - 8
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -20,6 +20,19 @@ function unmockWin32ForIsolatedRuntime(): void {
   vi.doUnmock('@deepseek-ai/dsh-win32-process')
 }
 
+function mockNodePtyForIsolatedRuntime(spawn: unknown): void {
+  vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({
+    createLazyRequire: (specifier: string) => () => {
+      if (specifier === 'node-pty') return { spawn }
+      throw new Error(`unexpected lazy dependency ${specifier}`)
+    },
+  }))
+}
+
+function unmockLazyRequireForIsolatedRuntime(): void {
+  vi.doUnmock('@deepseek-ai/dsh-lazy-require')
+}
+
 function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
   // Windows has no bash; the suite's simple commands translate to node one-liners.
   const argv = process.platform === 'win32'
@@ -440,7 +453,7 @@ describe('LocalSubprocessRuntime', () => {
     }
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
-    vi.doMock('node-pty', () => ({ spawn: () => terminal }))
+    mockNodePtyForIsolatedRuntime(() => terminal)
     vi.doMock('../src/process-inspector.ts', async importOriginal => ({
       ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
       createProcessInspector: () => inspector,
@@ -463,7 +476,7 @@ describe('LocalSubprocessRuntime', () => {
       await expect.poll(() => (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
       await fiber.dispose()
     } finally {
-      vi.doUnmock('node-pty')
+      unmockLazyRequireForIsolatedRuntime()
       vi.doUnmock('../src/process-inspector.ts')
       vi.doUnmock('../src/linux-scope.ts')
       unmockWin32ForIsolatedRuntime()
@@ -531,7 +544,7 @@ describe('LocalSubprocessRuntime', () => {
 
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
-    vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
+    mockNodePtyForIsolatedRuntime(nodePtySpawn)
     vi.doMock('../src/linux-scope.ts', () => ({
       signalLinuxDirectProcess,
       launchLinuxScope: vi.fn(),
@@ -597,7 +610,7 @@ describe('LocalSubprocessRuntime', () => {
     } finally {
       await fiber?.dispose()
       directProbe.mockRestore()
-      vi.doUnmock('node-pty')
+      unmockLazyRequireForIsolatedRuntime()
       vi.doUnmock('../src/linux-scope.ts')
       unmockWin32ForIsolatedRuntime()
       vi.resetModules()
@@ -632,7 +645,7 @@ describe('LocalSubprocessRuntime', () => {
 
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
-    vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
+    mockNodePtyForIsolatedRuntime(nodePtySpawn)
     vi.doMock('../src/linux-scope.ts', () => ({
       signalLinuxDirectProcess,
       launchLinuxScope: vi.fn(),
@@ -655,7 +668,7 @@ describe('LocalSubprocessRuntime', () => {
       expect(cleanup).toHaveBeenCalledOnce()
     } finally {
       await fiber?.dispose()
-      vi.doUnmock('node-pty')
+      unmockLazyRequireForIsolatedRuntime()
       vi.doUnmock('../src/linux-scope.ts')
       unmockWin32ForIsolatedRuntime()
       vi.resetModules()
@@ -676,7 +689,7 @@ describe('LocalSubprocessRuntime', () => {
     }
     vi.resetModules()
     mockWin32ForIsolatedRuntime()
-    vi.doMock('node-pty', () => ({ spawn: () => terminal }))
+    mockNodePtyForIsolatedRuntime(() => terminal)
     try {
       const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
       const ctx = new Context()
@@ -711,7 +724,7 @@ describe('LocalSubprocessRuntime', () => {
       await fiber.dispose()
       expect(disposalErrors).toHaveLength(1)
     } finally {
-      vi.doUnmock('node-pty')
+      unmockLazyRequireForIsolatedRuntime()
       unmockWin32ForIsolatedRuntime()
       vi.resetModules()
     }

+ 3 - 0
packages/subprocess/subprocess-local/tsconfig.json

@@ -22,6 +22,9 @@
     },
     {
       "path": "../../util/timeout"
+    },
+    {
+      "path": "../../util/lazy-require"
     }
   ]
 }

+ 1 - 0
packages/subprocess/win32-process/package.json

@@ -30,6 +30,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "koffi": "^3.1.0"
   },
   "devDependencies": {

+ 64 - 49
packages/subprocess/win32-process/src/ffi.ts

@@ -1,26 +1,24 @@
 /** Lazy Koffi bindings for generic Win32 process, stdio, and Job operations. */
 
-import koffi from 'koffi'
 import * as abi from './abi.ts'
 import { Win32Error } from './errors.ts'
+import { requireKoffi, type Koffi } from './koffi.ts'
 
 declare const nativePtr: unique symbol
 /** Koffi native pointer branded against accidental numeric use. */
 export type NativePtr = bigint & { readonly [nativePtr]: true }
 
-type Ptr = ReturnType<typeof koffi.pointer>
-const PVOID: Ptr = koffi.pointer('void')
-const PPVOID: Ptr = koffi.pointer(PVOID)
+type Ptr = ReturnType<Koffi['pointer']>
 
 /** Loaded Win32 libraries and the shared stdcall binder used by process extensions. */
 export interface Win32BindingContext {
   /** Kernel process, handle, pipe, and Job APIs. */
-  readonly kernel32: ReturnType<typeof koffi.load>
+  readonly kernel32: ReturnType<Koffi['load']>
   /** Token and security APIs. */
-  readonly advapi32: ReturnType<typeof koffi.load>
+  readonly advapi32: ReturnType<Koffi['load']>
   /** Bind one stdcall function from a loaded Win32 library. */
   readonly bind: (
-    library: ReturnType<typeof koffi.load>,
+    library: ReturnType<Koffi['load']>,
     name: string,
     result: Ptr | string,
     args: Array<Ptr | string>,
@@ -129,51 +127,65 @@ export interface CurrentTokenProcessBindings extends Win32ProcessBindings {
   uvGetOsfhandle(fileDescriptor: number): NativePtr | null
 }
 
-/** Koffi STARTUPINFOW layout. */
-export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', {
-  cb: 'uint32',
-  lpReserved: 'str16',
-  lpDesktop: 'str16',
-  lpTitle: 'str16',
-  dwX: 'uint32',
-  dwY: 'uint32',
-  dwXSize: 'uint32',
-  dwYSize: 'uint32',
-  dwXCountChars: 'uint32',
-  dwYCountChars: 'uint32',
-  dwFillAttribute: 'uint32',
-  dwFlags: 'uint32',
-  wShowWindow: 'uint16',
-  cbReserved2: 'uint16',
-  lpReserved2: koffi.pointer('uint8'),
-  hStdInput: PVOID,
-  hStdOutput: PVOID,
-  hStdError: PVOID,
-})
+interface Win32Types {
+  PVOID: Ptr
+  PPVOID: Ptr
+  STARTUPINFOW: ReturnType<Koffi['struct']>
+  PROCESS_INFORMATION: ReturnType<Koffi['struct']>
+}
 
-/** Koffi PROCESS_INFORMATION layout. */
-export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', {
-  hProcess: PVOID,
-  hThread: PVOID,
-  dwProcessId: 'uint32',
-  dwThreadId: 'uint32',
-})
+let cachedTypes: Win32Types | undefined
 
-/* v8 ignore start -- ABI guards are pinned by native header probes. */
-if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) {
-  throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, expected ${abi.STARTUPINFOW_SIZE}`)
+/** Resolve Koffi pointer and process layouts on the first native operation. */
+function win32Types(): Win32Types {
+  if (cachedTypes !== undefined) return cachedTypes
+  const koffi = requireKoffi()
+  const PVOID = koffi.pointer('void')
+  const PPVOID = koffi.pointer(PVOID)
+  const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', {
+    cb: 'uint32', lpReserved: 'str16', lpDesktop: 'str16', lpTitle: 'str16',
+    dwX: 'uint32', dwY: 'uint32', dwXSize: 'uint32', dwYSize: 'uint32',
+    dwXCountChars: 'uint32', dwYCountChars: 'uint32', dwFillAttribute: 'uint32',
+    dwFlags: 'uint32', wShowWindow: 'uint16', cbReserved2: 'uint16',
+    lpReserved2: koffi.pointer('uint8'), hStdInput: PVOID, hStdOutput: PVOID, hStdError: PVOID,
+  })
+  const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', {
+    hProcess: PVOID, hThread: PVOID, dwProcessId: 'uint32', dwThreadId: 'uint32',
+  })
+  /* v8 ignore start -- ABI guards are pinned by native header probes. */
+  if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) {
+    throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, expected ${abi.STARTUPINFOW_SIZE}`)
+  }
+  if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) {
+    throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, expected ${abi.PROCESS_INFORMATION_SIZE}`)
+  }
+  /* v8 ignore stop */
+  return cachedTypes = { PVOID, PPVOID, STARTUPINFOW, PROCESS_INFORMATION }
 }
-if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) {
-  throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, expected ${abi.PROCESS_INFORMATION_SIZE}`)
+
+/**
+ * Materialize the Koffi STARTUPINFOW layout on first native use.
+ * @returns the cached native struct type.
+ */
+export function startupInfoType(): ReturnType<Koffi['struct']> {
+  return win32Types().STARTUPINFOW
+}
+
+/**
+ * Materialize the Koffi PROCESS_INFORMATION layout on first native use.
+ * @returns the cached native struct type.
+ */
+export function processInformationType(): ReturnType<Koffi['struct']> {
+  return win32Types().PROCESS_INFORMATION
 }
-/* v8 ignore stop */
 
 /**
  * Allocate a pointer-sized out-parameter slot.
  * @returns allocated native slot.
  */
 export function allocPtrSlot(): NativePtr {
-  return koffi.alloc(PVOID, 1) as NativePtr
+  const { PVOID } = win32Types()
+  return requireKoffi().alloc(PVOID, 1) as NativePtr
 }
 
 /**
@@ -181,7 +193,7 @@ export function allocPtrSlot(): NativePtr {
  * @returns allocated native slot.
  */
 export function allocUint32(): NativePtr {
-  return koffi.alloc('uint32', 1) as NativePtr
+  return requireKoffi().alloc('uint32', 1) as NativePtr
 }
 
 /**
@@ -190,7 +202,7 @@ export function allocUint32(): NativePtr {
  * @returns decoded pointer, or null for address zero.
  */
 export function decodePtr(slot: NativePtr): NativePtr | null {
-  const value = koffi.decode(slot, PVOID) as NativePtr | null
+  const value = requireKoffi().decode(slot, win32Types().PVOID) as NativePtr | null
   return isNullPtr(value) ? null : value
 }
 
@@ -200,7 +212,7 @@ export function decodePtr(slot: NativePtr): NativePtr | null {
  * @returns decoded unsigned value.
  */
 export function decodeUint32(slot: NativePtr): number {
-  return koffi.decode(slot, 'uint32') as number
+  return requireKoffi().decode(slot, 'uint32') as number
 }
 
 /**
@@ -208,7 +220,7 @@ export function decodeUint32(slot: NativePtr): number {
  * @returns allocated struct pointer.
  */
 export function allocStartupInfo(): NativePtr {
-  return koffi.alloc(STARTUPINFOW, 1) as NativePtr
+  return requireKoffi().alloc(win32Types().STARTUPINFOW, 1) as NativePtr
 }
 
 /**
@@ -217,7 +229,7 @@ export function allocStartupInfo(): NativePtr {
  * @param fields - fields required for inherited stdio.
  */
 export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void {
-  koffi.encode(startupInfo, STARTUPINFOW, fields)
+  requireKoffi().encode(startupInfo, win32Types().STARTUPINFOW, fields)
 }
 
 /**
@@ -225,7 +237,7 @@ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInp
  * @returns allocated struct pointer.
  */
 export function allocProcessInfo(): NativePtr {
-  return koffi.alloc(PROCESS_INFORMATION, 1) as NativePtr
+  return requireKoffi().alloc(win32Types().PROCESS_INFORMATION, 1) as NativePtr
 }
 
 /**
@@ -234,7 +246,7 @@ export function allocProcessInfo(): NativePtr {
  * @returns process/thread handles and ids.
  */
 export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput {
-  return koffi.decode(processInfo, PROCESS_INFORMATION) as ProcessInfoOutput
+  return requireKoffi().decode(processInfo, win32Types().PROCESS_INFORMATION) as ProcessInfoOutput
 }
 
 let cachedContext: Win32BindingContext | undefined
@@ -243,6 +255,7 @@ let cached: CurrentTokenProcessBindings | undefined
 /* v8 ignore start -- exercised by native Windows ABI and sandbox jobs. */
 function bindingContext(): Win32BindingContext {
   if (cachedContext !== undefined) return cachedContext
+  const koffi = requireKoffi()
   const kernel32 = koffi.load('kernel32.dll')
   const advapi32 = koffi.load('advapi32.dll')
   const bind = (
@@ -257,6 +270,8 @@ function bindingContext(): Win32BindingContext {
 
 function bindings(): CurrentTokenProcessBindings {
   if (cached !== undefined) return cached
+  const koffi = requireKoffi()
+  const { PVOID, PPVOID, STARTUPINFOW, PROCESS_INFORMATION } = win32Types()
   const { kernel32, advapi32, bind } = bindingContext()
   const node = koffi.load(null)
   cached = {

+ 10 - 0
packages/subprocess/win32-process/src/koffi.ts

@@ -0,0 +1,10 @@
+/** Process-realm lazy access to Koffi's CommonJS entry. */
+
+import type koffi from 'koffi'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
+
+/** Koffi runtime export type. */
+export type Koffi = typeof koffi
+
+/** Load Koffi on the first Win32 native operation. */
+export const requireKoffi = createLazyRequire<Koffi>('koffi', import.meta.url)

+ 5 - 4
packages/subprocess/win32-process/src/process.ts

@@ -1,6 +1,5 @@
 /** Typed Win32 process operations over the shared binding table. */
 
-import koffi from 'koffi'
 import * as abi from './abi.ts'
 import { inheritedControlStdio } from './control-stdio.ts'
 import {
@@ -17,6 +16,7 @@ import {
   throwWin32,
 } from './ffi.ts'
 import type { CurrentTokenProcessBindings, NativePtr, Win32ProcessBindings } from './ffi.ts'
+import { requireKoffi } from './koffi.ts'
 
 /**
  * Quote one argument according to CommandLineToArgvW parsing.
@@ -133,7 +133,7 @@ interface PipePair {
 }
 
 function freeNative(pointer: NativePtr | undefined): void {
-  if (pointer !== undefined) koffi.free(pointer)
+  if (pointer !== undefined) requireKoffi().free(pointer)
 }
 
 function closeBestEffort(api: Win32ProcessBindings, handle: NativePtr | null | undefined): void {
@@ -158,7 +158,7 @@ function createPipe(api: Win32ProcessBindings, owned: Set<NativePtr>): PipePair
     return { read, write }
   } finally {
     freeNative(writeSlot)
-    koffi.free(readSlot)
+    requireKoffi().free(readSlot)
   }
 }
 
@@ -442,6 +442,7 @@ function spawnJobProcess(
       ? undefined
       : inheritedControlStdio(api, { ...stdio, control: stdio.control })
     if (controlBytes !== undefined) {
+      const koffi = requireKoffi()
       controlDescriptorBlock = { pointer: koffi.alloc('uint8', controlBytes.length) as NativePtr, length: controlBytes.length }
       koffi.encode(controlDescriptorBlock.pointer, 'uint8', controlBytes, controlBytes.length)
     }
@@ -591,7 +592,7 @@ export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr):
     if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess')
     return decodeUint32(exitCodeSlot)
   } finally {
-    koffi.free(exitCodeSlot)
+    requireKoffi().free(exitCodeSlot)
   }
 }
 

+ 3 - 3
packages/subprocess/win32-process/tests/ffi.spec.ts

@@ -8,13 +8,13 @@ import {
   throwLastError,
 } from '../src/index.ts'
 import { PROCESS_INFORMATION_SIZE, STARTUPINFOW_SIZE } from '../src/abi.ts'
-import { PROCESS_INFORMATION, STARTUPINFOW, errorText } from '../src/ffi.ts'
+import { errorText, processInformationType, startupInfoType } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
 describe('shared Win32 process ABI', () => {
   it('matches the verified x64 structure sizes', () => {
-    expect(STARTUPINFOW.size).toBe(STARTUPINFOW_SIZE)
-    expect(PROCESS_INFORMATION.size).toBe(PROCESS_INFORMATION_SIZE)
+    expect(startupInfoType().size).toBe(STARTUPINFOW_SIZE)
+    expect(processInformationType().size).toBe(PROCESS_INFORMATION_SIZE)
   })
 
   it('handles NULL pointer out-parameters', () => {

+ 7 - 7
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -17,7 +17,7 @@ import {
   JobObjectBasicAccountingInformation,
   WAIT_TIMEOUT,
 } from '../src/abi.ts'
-import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts'
+import { processInformationType, startupInfoType } from '../src/ffi.ts'
 import type {
   CurrentTokenProcessSpawnOptions,
   CurrentTokenProcessBindings,
@@ -50,7 +50,7 @@ function api(overrides: Partial<CurrentTokenProcessBindings> = {}): CurrentToken
     uvGetOsfhandle: vi.fn((fileDescriptor: number) => BigInt(100 + fileDescriptor)),
     setHandleInformation: vi.fn(() => 1),
     createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
-      koffi.encode(info, PROCESS_INFORMATION, {
+      koffi.encode(info, processInformationType(), {
         hProcess: 60n,
         hThread: 61n,
         dwProcessId: 1234,
@@ -82,9 +82,9 @@ describe('ordinary Job process operations', () => {
       getFileType: vi.fn(handle => handle === 107n ? 3 : standardType),
       setHandleInformation: flags,
       createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, startupPointer, processInfo) => {
-        const startup = koffi.decode(startupPointer, STARTUPINFOW) as { cbReserved2: number; lpReserved2: NativePtr }
+        const startup = koffi.decode(startupPointer, startupInfoType()) as { cbReserved2: number; lpReserved2: NativePtr }
         descriptorBytes = Buffer.from(koffi.decode(startup.lpReserved2, 'uint8', startup.cbReserved2) as number[])
-        koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
+        koffi.encode(processInfo, processInformationType(), { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
         return 1
       }),
     })
@@ -129,7 +129,7 @@ describe('ordinary Job process operations', () => {
       info: NativePtr,
     ) => {
       events.push('create')
-      koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
+      koffi.encode(info, processInformationType(), { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
       return 1
     })
     const bindings = api({
@@ -190,8 +190,8 @@ describe('ordinary Job process operations', () => {
       setHandleInformation,
       uvGetOsfhandle,
       createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => {
-        startup = koffi.decode(infoPtr, STARTUPINFOW) as Record<string, unknown>
-        koffi.encode(processInfo, PROCESS_INFORMATION, {
+        startup = koffi.decode(infoPtr, startupInfoType()) as Record<string, unknown>
+        koffi.encode(processInfo, processInformationType(), {
           hProcess: 60n,
           hThread: 61n,
           dwProcessId: 1234,

+ 10 - 4
packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts

@@ -1,5 +1,5 @@
 import koffi from 'koffi'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import {
   drainPipe,
   spawnInheritedJobProcess,
@@ -7,13 +7,19 @@ import {
   waitForProcessExit,
 } from '../src/index.ts'
 import * as ffi from '../src/ffi.ts'
-import { PROCESS_INFORMATION } from '../src/ffi.ts'
+import { processInformationType } from '../src/ffi.ts'
+import * as koffiLoader from '../src/koffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts'
 
 vi.mock('../src/ffi.ts', { spy: true })
+vi.mock('../src/koffi.ts', { spy: true })
 
 const PVOID = koffi.pointer('void')
 
+beforeEach(() => {
+  vi.mocked(koffiLoader.requireKoffi).mockReturnValue(koffi)
+})
+
 afterEach(() => {
   vi.restoreAllMocks()
 })
@@ -47,7 +53,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => {
       getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
       setHandleInformation: vi.fn(() => 1),
       createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
-        koffi.encode(info, PROCESS_INFORMATION, {
+        koffi.encode(info, processInformationType(), {
           hProcess: 60n,
           hThread: 61n,
           dwProcessId: 1234,
@@ -83,7 +89,7 @@ describe('shared process allocation cleanup', () => {
       }),
       setHandleInformation: vi.fn(() => 1),
       createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
-        koffi.encode(info, PROCESS_INFORMATION, {
+        koffi.encode(info, processInformationType(), {
           hProcess: 60n,
           hThread: 61n,
           dwProcessId: 1234,

+ 5 - 5
packages/subprocess/win32-process/tests/process-failure-paths.spec.ts

@@ -17,7 +17,7 @@ import {
 } from '../src/index.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 import * as abi from '../src/abi.ts'
-import { PROCESS_INFORMATION } from '../src/ffi.ts'
+import { processInformationType } from '../src/ffi.ts'
 
 const PVOID = koffi.pointer('void')
 
@@ -88,7 +88,7 @@ function pipeOkApi(overrides: Partial<Win32ProcessBindings> = {}): {
       _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
       _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
     ) => {
-      koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
+      koffi.encode(processInfo, processInformationType(), { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
       return 1
     }),
     getLastError: vi.fn(() => 5),
@@ -152,7 +152,7 @@ describe('spawn pipe failures close their handles', () => {
         _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
         _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
       ) => {
-        koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
+        koffi.encode(processInfo, processInformationType(), { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
         return 1
       }),
     })
@@ -185,7 +185,7 @@ describe('spawnInheritedJobProcess failure paths', () => {
         _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
         _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
       ) => {
-        koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
+        koffi.encode(processInfo, processInformationType(), { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
         return 1
       }),
       assignProcessToJobObject: vi.fn(() => 1),
@@ -243,7 +243,7 @@ describe('spawnInheritedJobProcess failure paths', () => {
         _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
         _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
       ) => {
-        koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
+        koffi.encode(processInfo, processInformationType(), { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
         return 1
       }),
     })

+ 4 - 4
packages/subprocess/win32-process/tests/process.spec.ts

@@ -7,7 +7,7 @@ import {
   spawnPipedProcess,
 } from '../src/index.ts'
 import { CREATE_SUSPENDED } from '../src/abi.ts'
-import { PROCESS_INFORMATION } from '../src/ffi.ts'
+import { processInformationType } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
 const PVOID = koffi.pointer('void')
@@ -24,7 +24,7 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
     overrides.createProcessAsUserW
     ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
       events.push('create')
-      koffi.encode(info, PROCESS_INFORMATION, {
+      koffi.encode(info, processInformationType(), {
         hProcess: 60n,
         hThread: 61n,
         dwProcessId: 1234,
@@ -176,7 +176,7 @@ describe('spawnInheritedJobProcess', () => {
       closeHandle,
       terminateProcess,
       createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
-        koffi.encode(info, PROCESS_INFORMATION, {
+        koffi.encode(info, processInformationType(), {
           hProcess: 60n,
           hThread: 0n,
           dwProcessId: 1234,
@@ -232,7 +232,7 @@ describe('wait and pipe cleanup', () => {
       }),
       setHandleInformation: vi.fn(() => 1),
       createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
-        koffi.encode(info, PROCESS_INFORMATION, {
+        koffi.encode(info, processInformationType(), {
           hProcess: 60n,
           hThread: 0n,
           dwProcessId: 1234,

+ 3 - 0
packages/subprocess/win32-process/tsconfig.json

@@ -8,6 +8,9 @@
   "references": [
     {
       "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/lazy-require"
     }
   ]
 }

+ 1 - 0
packages/terminal/terminal-bash/package.json

@@ -37,6 +37,7 @@
     "@deepseek-ai/dsh-session-projection": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-lazy-require": "workspace:^",
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "@xterm/headless": "^6.0.0"

+ 17 - 7
packages/terminal/terminal-bash/src/index.ts

@@ -170,6 +170,16 @@ async function startupSession(
   }
 }
 
+/** Reject a failed startup only after its unpublished resources reach quiescence. */
+async function rejectAfterStartupCleanup(error: unknown, cleanup: () => Promise<void>): Promise<never> {
+  try {
+    await cleanup()
+  } catch (cleanupError: unknown) {
+    throw new TerminalBackendCleanupError(error, cleanupError)
+  }
+  throw error
+}
+
 /** Local shell backend registered under the configured type. */
 export class BashTerminalBackend implements TerminalBackend {
   readonly type: string
@@ -205,17 +215,17 @@ export class BashTerminalBackend implements TerminalBackend {
       graceMs: this.config.disposeGraceMs,
       signal: spec.signal,
     })
-    const session = this.createSession(terminal, this.config)
+    let session: LocalPtySession
+    try {
+      session = this.createSession(terminal, this.config)
+    } catch (error) {
+      return rejectAfterStartupCleanup(error, () => terminal.terminate())
+    }
     try {
       await startupSession(session, this.config.shellDialect, this.config.timeoutMs, spec.signal)
       return session
     } catch (error) {
-      try {
-        await session.close('PTY startup failed')
-      } catch (closeError: unknown) {
-        throw new TerminalBackendCleanupError(error, closeError)
-      }
-      throw error
+      return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
     }
   }
 }

+ 3 - 3
packages/terminal/terminal-bash/src/session.ts

@@ -1,8 +1,8 @@
 /** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */
 
 import { Buffer } from 'node:buffer'
-import { createRequire } from 'node:module'
 import type { IDisposable, Terminal as HeadlessTerminalType } from '@xterm/headless'
+import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import type {
   SubprocessOutcome,
   SubprocessTerminalForeground,
@@ -25,8 +25,7 @@ import type {
 import type { ResolvedConfig } from './config.ts'
 import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
 
-// Node exposes this package's CommonJS main as default-only, so load its named export through require.
-const { Terminal: HeadlessTerminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless')
+const requireHeadless = createLazyRequire<typeof import('@xterm/headless')>('@xterm/headless', import.meta.url)
 
 function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
   if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
@@ -281,6 +280,7 @@ export class LocalPtySession implements TerminalBackendSession {
     private readonly config: ResolvedConfig,
   ) {
     this.pid = terminal.pid
+    const { Terminal: HeadlessTerminal } = requireHeadless()
     this.emulator = new HeadlessTerminal({ cols: config.cols, rows: config.rows, scrollback: 0 })
     this.emulatorData = this.emulator.onData((data) => {
       this.pendingResponseWrites += 1

+ 35 - 0
packages/terminal/terminal-bash/tests/index.spec.ts

@@ -158,6 +158,41 @@ describe('BashTerminalBackend startup rollback', () => {
     } satisfies Partial<TerminalBackendCleanupError>))
   })
 
+  it('awaits terminal cleanup when session construction fails', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionProjectionRegistry)
+    await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
+    const quiescent = Promise.withResolvers<undefined>()
+    const terminal = {
+      ...terminalHandle(),
+      terminate: vi.fn(() => quiescent.promise),
+    }
+    const constructionStarted = Promise.withResolvers<undefined>()
+    const failure = new Error('terminal emulator unavailable')
+    const backend = new BashTerminalBackend(
+      ctx,
+      config(),
+      async () => terminal,
+      () => {
+        constructionStarted.resolve(undefined)
+        throw failure
+      },
+    )
+
+    const spawning = backend.spawn(spec(agent(ctx)))
+    await constructionStarted.promise
+    expect(terminal.terminate).toHaveBeenCalledOnce()
+    let settled = false
+    void spawning.then(
+      () => { settled = true },
+      () => { settled = true },
+    )
+    await Promise.resolve()
+    expect(settled).toBe(false)
+    quiescent.resolve(undefined)
+    await expect(spawning).rejects.toBe(failure)
+  })
+
   it('starts startup rollback when cancellation wins a stalled initialization', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionProjectionRegistry)

+ 3 - 0
packages/terminal/terminal-bash/tsconfig.json

@@ -37,6 +37,9 @@
     },
     {
       "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../util/lazy-require"
     }
   ]
 }

+ 2 - 2
packages/typert/generator/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/typert/generator/README.md
-README.md: 7177e6a34520ab07645727edb15c5a726b9a2a4c
-README.zh.md: c1add1907c7c14ee7dd440dfaceee0ece0aa036e
+README.md: bcbde084425a3a61deb8c9a596dca808f144c1da
+README.zh.md: 450573b57d9236f9f64ad37a339afdb6e428655b

+ 2 - 2
packages/typert/generator/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-typert-generator` lets maintainers turn public TypeScript types into build artifacts and compiler-independent models. Packages opt in through the `./typert` and optional `./client/typert` exports, and generation rejects declarations, publish lists, Remote exports, or Zod projections that it cannot represent correctly. Repository builds can emit executable schemas and matching declarations, while tools can call `WorkspaceAnalyzer` for inspection or catalog generation without publishing artifacts. Generation runs only at build time and never in a live agent session.
+`dsh-typert-generator` lets maintainers turn public TypeScript types into build artifacts and compiler-independent models. Packages opt in through the `./typert` and optional `./client/typert` exports, and generation rejects declarations, publish lists, Remote exports, or Zod projections that it cannot represent correctly. Repository builds emit executable schema factories and matching declarations, while tools can call `WorkspaceAnalyzer` for inspection or catalog generation without publishing artifacts. Generation runs only at build time and never in a live agent session.
 
 ## Table of Contents
 
@@ -83,7 +83,7 @@ Host and Client are independent TypeScript programs. Direct project references e
 
 ### Emission and publication contract
 
-`FaceModelEmitter` emits executable JavaScript containing supported Zod schemas and the `TYPERT` contribution, plus a declaration file whose schemas are typed `z.ZodType<SourceType>` through the package's public export; unsupported Zod projections fail. The Host face with Remote methods additionally emits `typert.remote-client.*` projections of Host Remote contracts for the Client. `WorkspaceTypertGenerator` validates each contributor's `package.json`: `./typert` and `./client/typert` (and `./remote` when Remote methods exist) must point at the exact generated files, and the `files` list must include them.
+`FaceModelEmitter` emits executable JavaScript containing success-cached Zod schema factories and the `TYPERT` contribution, plus a declaration file whose factories return `z.ZodType<SourceType>` through the package's public export; unsupported Zod projections fail. The Host face with Remote methods additionally emits `typert.remote-client.*` projections of Host Remote contracts for the Client. `WorkspaceTypertGenerator` validates each contributor's `package.json`: `./typert` and `./client/typert` (and `./remote` when Remote methods exist) must point at the exact generated files, and the `files` list must include them.
 
 ### Catalog projection
 

+ 2 - 2
packages/typert/generator/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-`dsh-typert-generator` 让维护者把公开的 TypeScript 类型转换为构建产物和与编译器无关的模型。包通过 `./typert` 和可选的 `./client/typert` 导出选择加入;如果声明、发布清单、Remote 导出或 Zod 投影无法被正确表示,生成过程就会失败。仓库构建可以生成可执行 schema 与配套声明,工具也可以调用 `WorkspaceAnalyzer` 完成检查或目录生成而不发布产物。生成过程只在构建时运行,绝不会进入实时 agent(智能体)会话。
+`dsh-typert-generator` 让维护者把公开的 TypeScript 类型转换为构建产物和与编译器无关的模型。包通过 `./typert` 和可选的 `./client/typert` 导出选择加入;如果声明、发布清单、Remote 导出或 Zod 投影无法被正确表示,生成过程就会失败。仓库构建会生成可执行 schema factory 与配套声明,工具也可以调用 `WorkspaceAnalyzer` 完成检查或目录生成而不发布产物。生成过程只在构建时运行,绝不会进入实时 agent(智能体)会话。
 
 ## 目录
 
@@ -83,7 +83,7 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定
 
 ### 生成与发布约定
 
-`FaceModelEmitter` 输出包含受支持 Zod schema 与 `TYPERT` 贡献的可执行 JavaScript,以及把 schema 通过包的公开导出标注为 `z.ZodType<SourceType>` 的声明文件;不支持的 Zod 投影会失败。含 Remote 方法的 Host face 还会额外为 Client 生成 Host Remote 约定的 `typert.remote-client.*` 投影。`WorkspaceTypertGenerator` 校验每个贡献方的 `package.json`:`./typert` 与 `./client/typert`(存在 Remote 方法时还有 `./remote`)必须指向精确的生成文件,且 `files` 清单必须包含它们。
+`FaceModelEmitter` 输出包含只缓存成功结果的 Zod schema factory 与 `TYPERT` 贡献的可执行 JavaScript,以及把 factory 通过包的公开导出标注为返回 `z.ZodType<SourceType>` 的声明文件;不支持的 Zod 投影会失败。含 Remote 方法的 Host face 还会额外为 Client 生成 Host Remote 约定的 `typert.remote-client.*` 投影。`WorkspaceTypertGenerator` 校验每个贡献方的 `package.json`:`./typert` 与 `./client/typert`(存在 Remote 方法时还有 `./remote`)必须指向精确的生成文件,且 `files` 清单必须包含它们。
 
 ### 目录投影
 

+ 7 - 6
packages/typert/generator/src/emitter.ts

@@ -202,7 +202,7 @@ export class FaceModelEmitter {
     lines.push(`  face: ${quote(this.face.face)},`)
     lines.push('  schemas: [')
     for (const schema of schemas.exports) {
-      lines.push(`    { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
+      lines.push(`    { name: ${quote(schema.exportName)}, create: ${schema.exportName} },`)
     }
     lines.push('  ],')
     lines.push('  invocations: [')
@@ -232,7 +232,7 @@ export class FaceModelEmitter {
     }
     lines.push('')
     for (const schema of schemas.exports) {
-      lines.push(`export declare const ${schema.exportName}: z.ZodType<${schema.exportName}$source>`)
+      lines.push(`export declare const ${schema.exportName}: () => z.ZodType<${schema.exportName}$source>`)
     }
     if (schemas.exports.length > 0) lines.push('')
     // The Loader validates and narrows this generated module boundary before
@@ -556,7 +556,8 @@ class SchemaEmitter {
   emit(): SchemaArtifact {
     const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration))
     for (const boundary of this.boundaries) {
-      definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`)
+      const name = this.boundaryName(boundary.key)
+      definitions.push(`let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.typeSchema(boundary.type)})`)
     }
     const exports = this.schemas.map((model): SchemaExport => ({
       model,
@@ -573,7 +574,7 @@ class SchemaEmitter {
   private declarationDefinition(declaration: TypeDeclarationModel): string {
     const name = this.schemaName(declaration.id)
     if (declaration.typeParameters.length === 0) {
-      return `const ${name} = ${this.declarationSchema(declaration, new Map())}`
+      return `let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.declarationSchema(declaration, new Map())})`
     }
     const parameters = declaration.typeParameters.map((parameter, index) =>
       [`type${String(index)}$schema`, parameter.id] as const)
@@ -655,7 +656,7 @@ class SchemaEmitter {
         if (node.arguments.length > 0) {
           this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`)
         }
-        return `z.lazy(() => ${name})`
+        return `z.lazy(() => ${name}())`
       }
       const arguments_ = this.declarationArguments(node, declaration, substitutions)
       return `z.lazy(() => ${name}(${arguments_.join(', ')}))`
@@ -873,7 +874,7 @@ function strictCodec(boundary: RemoteBoundaryModel, schema: string): string {
     '{',
     '  mode: \'strict\',',
     `  typeSymbol: ${quote(boundary.typeSymbol)},`,
-    `  schema: ${schema},`,
+    `  create: ${schema},`,
     '}',
   ].join('\n')
 }

+ 5 - 4
packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap

@@ -4,10 +4,11 @@ exports[`FaceModelEmitter > emits runnable Zod JavaScript, precise declarations,
 "/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */
 import { z } from 'zod'
 
-const Payload$schema = z.object({
+let Payload$schema$value
+const Payload$schema = () => (Payload$schema$value ??= z.object({
   'name': z.string(),
   'count': z.number().optional(),
-}).describe('Runtime-validating data root.')
+}).describe('Runtime-validating data root.'))
 
 export const Payload = Payload$schema
 
@@ -15,7 +16,7 @@ export const TYPERT = {
   package: '@fixture/host',
   face: 'host',
   schemas: [
-    { name: 'Payload', schema: Payload },
+    { name: 'Payload', create: Payload },
   ],
   invocations: [
   ],
@@ -321,7 +322,7 @@ exports[`FaceModelEmitter > emits runnable Zod JavaScript, precise declarations,
 import type { z } from 'zod'
 import type { Payload as Payload$source } from '@fixture/host'
 
-export declare const Payload: z.ZodType<Payload$source>
+export declare const Payload: () => z.ZodType<Payload$source>
 
 export declare const TYPERT: unknown
 "

+ 15 - 15
packages/typert/generator/tests/remote-model.spec.ts

@@ -25,9 +25,9 @@ interface RuntimeDescriptor {
   readonly parameters: readonly {
     readonly wire: string
     readonly acceptsUndefined?: true
-    readonly codec: { readonly schema: RuntimeSchema }
+    readonly codec: { readonly create: () => RuntimeSchema }
   }[]
-  readonly result: { readonly schema: RuntimeSchema }
+  readonly result: { readonly create: () => RuntimeSchema }
 }
 
 interface RuntimeRemoteModule {
@@ -152,10 +152,10 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
     expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
     const create = generated.TYPERT_REMOTE.descriptors[0]
     expect(create?.cancellation).toEqual({ parameter: 'signal' })
-    expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
-    expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
-    expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
-    expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
+    expect(create?.parameters[1]?.codec.create().safeParse({ title: 'ship' }).success).toBe(true)
+    expect(create?.parameters[1]?.codec.create().safeParse({ title: 1 }).success).toBe(false)
+    expect(create?.result.create().safeParse({ ref: 'goal-1' }).success).toBe(true)
+    expect(create?.result.create().safeParse({ ref: 1 }).success).toBe(false)
     expect(generated.TYPERT_REMOTE.descriptors[2]?.mode).toBe('stream')
 
     const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
@@ -208,15 +208,15 @@ export type {`,
     const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe'))
     const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear'))
     expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true)
-    expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true)
-    expect(maybe?.result.schema.safeParse(undefined).success).toBe(true)
-    expect(clear?.result.schema.safeParse(undefined).success).toBe(true)
-    expect(clear?.result.schema.safeParse(null).success).toBe(false)
+    expect(maybe?.parameters[0]?.codec.create().safeParse(undefined).success).toBe(true)
+    expect(maybe?.result.create().safeParse(undefined).success).toBe(true)
+    expect(clear?.result.create().safeParse(undefined).success).toBe(true)
+    expect(clear?.result.create().safeParse(null).success).toBe(false)
     const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled'))
     expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined()
     expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true)
-    expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true)
-    expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false)
+    expect(labelled?.parameters[1]?.codec.create().safeParse(undefined).success).toBe(true)
+    expect(labelled?.parameters[1]?.codec.create().safeParse(7).success).toBe(false)
   })
 
   it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
@@ -282,13 +282,13 @@ export type GenericResult = {
     const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
     const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
     const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch'))
-    const schema = dispatch?.parameters[0]?.codec.schema
+    const schema = dispatch?.parameters[0]?.codec.create()
     expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true)
     expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false)
     expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true)
     expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false)
-    expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true)
-    expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
+    expect(dispatch?.result.create().safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true)
+    expect(dispatch?.result.create().safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
   })
 
   it('imports public type arguments nested under a named generic boundary', () => {

+ 2 - 2
packages/typert/generator/tests/schema-emitter.spec.ts

@@ -964,9 +964,9 @@ async function loadSchema(source: string): Promise<{ safeParse(value: unknown):
   const path = join(root, 'schema.mjs')
   writeFileSync(path, source)
   const generated = await import(`${pathToFileURL(path).href}?test=${Date.now()}-${String(temporaryRoots.length)}`) as {
-    Root: { safeParse(value: unknown): { success: boolean } }
+    Root: () => { safeParse(value: unknown): { success: boolean } }
   }
-  return generated.Root
+  return generated.Root()
 }
 
 function distinct(values: readonly string[]): string[] {

+ 7 - 6
packages/typert/generator/tests/type-model.spec.ts

@@ -1605,18 +1605,19 @@ describe('FaceModelEmitter', { timeout: 60_000 }, () => {
     const modulePath = join(root, 'host.mjs')
     writeFileSync(modulePath, artifact.js)
     const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as {
-      Payload: { safeParse(value: unknown): { success: boolean } }
+      Payload: () => { safeParse(value: unknown): { success: boolean } }
       TYPERT: {
         package: string
         face: string
-        schemas: { name: string; schema: unknown }[]
+        schemas: { name: string; create: () => unknown }[]
         model: { services: { key: string; members: { signature: string }[] }[] }
       }
     }
-    expect(generated.Payload.safeParse({ name: 'ready', count: 2 }).success).toBe(true)
-    expect(generated.Payload.safeParse({ name: 'ready', count: 'two' }).success).toBe(false)
+    expect(generated.Payload().safeParse({ name: 'ready', count: 2 }).success).toBe(true)
+    expect(generated.Payload().safeParse({ name: 'ready', count: 'two' }).success).toBe(false)
+    expect(generated.Payload()).toBe(generated.Payload())
     expect(generated.TYPERT).toMatchObject({ package: '@fixture/host', face: 'host' })
-    expect(generated.TYPERT.schemas[0]?.schema).toBe(generated.Payload)
+    expect(generated.TYPERT.schemas[0]?.create).toBe(generated.Payload)
     const demo = generated.TYPERT.model.services.find(service => service.key === 'demo')
     expect(demo).toMatchObject({ key: 'demo' })
     expect(demo?.members.map(member => member.signature)).toContain(
@@ -1631,7 +1632,7 @@ describe('FaceModelEmitter', { timeout: 60_000 }, () => {
       'import { Payload } from \'./host.js\'',
       'import type { Payload as SourcePayload } from \'@fixture/host\'',
       'import type { z } from \'zod\'',
-      'const precise: z.ZodType<SourcePayload> = Payload',
+      'const precise: () => z.ZodType<SourcePayload> = Payload',
       'void precise',
       '',
     ].join('\n'))

+ 2 - 2
packages/typert/loader/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/typert/loader/README.md
-README.md: 378be98dea5f47e4d6b0a0c70600e2a4c221c80b
-README.zh.md: e598127bba12e4057405231fe09199db18f9604d
+README.md: c45fbdebe7c2a05491515c3679ca2a3338285ca1
+README.zh.md: 9cb6ae6da5da30292d74e05932cd147090aa4dc8

+ 3 - 3
packages/typert/loader/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-With `dsh-typert-loader` mounted, every package that mounts in a Loader composition automatically contributes its generated Typert reflection and schemas to the runtime registry — and withdraws them when the package or the plugin unmounts. Packages without the generated export are skipped, so adding the plugin to any composition is safe. An explicit `packages` list covers plugins nested behind another Loader entry, whose fibers carry no resolvable package specifier. It is a Node-only plugin and needs the config-tree resolution anchor to resolve packages.
+With `dsh-typert-loader` mounted, every package that mounts in a Loader composition automatically contributes its generated Typert reflection and schema factories to the runtime registry — and withdraws them when the package or the plugin unmounts. Packages without the generated export are skipped, so adding the plugin to any composition is safe. An explicit `packages` list covers plugins nested behind another Loader entry, whose fibers carry no resolvable package specifier. It is a Node-only plugin and needs the config-tree resolution anchor to resolve packages.
 
 ## Table of Contents
 
@@ -44,7 +44,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### What gets registered
 
-Each qualifying Loader entry contributes its generated host-face reflection and schemas to the runtime registry. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded.
+Each qualifying Loader entry contributes its generated host-face reflection and schema factories to the runtime registry. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded.
 
 ### Observable behavior and failures
 
@@ -66,7 +66,7 @@ The plugin is an incremental scanner mirroring the client-modules node half: eve
 
 ### Manifest validation
 
-`validateTypertManifest()` is the module/file boundary: the manifest crosses from a build artifact into the typed registry, so every field is checked. The manifest must name the package that exports it, carry face `host`, hold zod v4 schema instances, and keep well-formed service, event, object, member, type, and documentation records; invocation descriptors must use strict codecs. Every failure names the package and the defect.
+`validateTypertManifest()` is the module/file boundary: the manifest crosses from a build artifact into the typed registry, so every field is checked. The manifest must name the package that exports it, carry face `host`, hold schema factories, and keep well-formed service, event, object, member, type, and documentation records; invocation descriptors must use strict codecs with factories. Every failure names the package and the defect.
 
 ### Caching and ownership
 

+ 3 - 3
packages/typert/loader/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-挂载 `dsh-typert-loader` 后,Loader 组合中每个挂载的包都会自动把其生成的 Typert 反射与 schema 贡献给运行时注册表——并在包或本插件卸载时自动撤销。没有该导出的包会被跳过,因此在任何 Loader 组合中挂载它都是安全的。显式 `packages` 用于覆盖嵌套在另一 Loader 配置项之下的插件,这些插件的 fiber 不携带可解析的包说明符。它是仅支持 Node 的插件,需要配置树解析锚点才能解析包。
+挂载 `dsh-typert-loader` 后,Loader 组合中每个挂载的包都会自动把其生成的 Typert 反射与 schema factory 贡献给运行时注册表——并在包或本插件卸载时自动撤销。没有该导出的包会被跳过,因此在任何 Loader 组合中挂载它都是安全的。显式 `packages` 用于覆盖嵌套在另一 Loader 配置项之下的插件,这些插件的 fiber 不携带可解析的包说明符。它是仅支持 Node 的插件,需要配置树解析锚点才能解析包。
 
 ## 目录
 
@@ -44,7 +44,7 @@ kind: "package-reference"
 
 ### 注册什么
 
-每个符合条件的 Loader 配置项都会把其生成的宿主侧反射与 schema 贡献给运行时注册表。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。
+每个符合条件的 Loader 配置项都会把其生成的宿主侧反射与 schema factory 贡献给运行时注册表。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。
 
 ### 可观察行为与失败
 
@@ -66,7 +66,7 @@ kind: "package-reference"
 
 ### Manifest 校验
 
-`validateTypertManifest()` 是模块/文件边界:manifest 从构建产物进入类型化注册表,因此每个字段都会被检查。manifest 必须指名导出它的包、携带 `host` face、持有 zod v4 schema 实例,并保持服务、事件、对象、成员、类型与文档记录格式正确;调用描述符必须使用严格编解码器。每次失败都会指名包与缺陷。
+`validateTypertManifest()` 是模块/文件边界:manifest 从构建产物进入类型化注册表,因此每个字段都会被检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并保持服务、事件、对象、成员、类型与文档记录格式正确;调用描述符必须使用带 factory 的严格编解码器。每次失败都会指名包与缺陷。
 
 ### 缓存与归属
 

+ 4 - 7
packages/typert/loader/src/index.ts

@@ -108,8 +108,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
     }
     const schema = value as Record<string, unknown>
     requireString(pkgName, schema, 'name', 'schema')
-    if (typeof schema.schema !== 'object' || schema.schema === null || !('_zod' in schema.schema)) {
-      throw new Error(`typert-loader: ${pkgName} TYPERT schema "${schema.name as string}" is not a zod v4 schema instance`)
+    if (typeof schema.create !== 'function') {
+      throw new Error(`typert-loader: ${pkgName} TYPERT schema "${schema.name as string}" has no create() factory`)
     }
   }
   const model = requireObject(pkgName, manifest.model, 'TYPERT.model')
@@ -273,11 +273,8 @@ function requireStrictCodec(pkgName: string, value: unknown, subject: string): v
     throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`)
   }
   requireString(pkgName, codec, 'typeSymbol', subject)
-  if (typeof codec.schema !== 'object'
-    || codec.schema === null
-    || !('_zod' in codec.schema)
-    || typeof (codec.schema as { parse?: unknown }).parse !== 'function') {
-    throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`)
+  if (typeof codec.create !== 'function') {
+    throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`)
   }
 }
 

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