فهرست منبع

fix(client): complete dynamic module bootstrap

imccyu 3 هفته پیش
والد
کامیت
cf603b847f
26فایلهای تغییر یافته به همراه530 افزوده شده و 159 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 21 49
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 21 49
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  5. 86 0
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  6. 86 0
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  7. 20 2
      apps/web/tests/assembled-boot.ts
  8. 2 2
      docs/subsystems/client-modules.i18n.yaml
  9. 11 3
      docs/subsystems/client-modules.md
  10. 11 3
      docs/subsystems/client-modules.zh.md
  11. 1 1
      packages/client/AGENTS.md
  12. 2 2
      packages/client/modules/README.i18n.yaml
  13. 5 3
      packages/client/modules/README.md
  14. 5 3
      packages/client/modules/README.zh.md
  15. 3 2
      packages/client/modules/src/client/manifest.ts
  16. 26 10
      packages/client/modules/src/client/system.ts
  17. 68 2
      packages/client/modules/tests/loader.client.spec.ts
  18. 101 1
      packages/client/modules/tests/node-half.client.spec.ts
  19. 2 2
      packages/client/web/README.i18n.yaml
  20. 3 3
      packages/client/web/README.md
  21. 3 3
      packages/client/web/README.zh.md
  22. 44 8
      packages/client/web/src/boot.ts
  23. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  24. 0 1
      scripts/verify-package-readme-model-experience.ts
  25. 0 3
      tsconfig.base.json
  26. 0 4
      vitest.config.ts

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

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

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

@@ -1,10 +1,10 @@
-# Agent Note: Client plugin loading — plain packages, dsh.client plugins, and the two-phase boot
+# Agent Note: Client plugin loading — lazy factories, Cordis lifecycle, and hot reload
 
 Status: implemented
 
 English | [中文](2026-07-23-client-plugin-loading-model.zh.md)
 
-> Scope: the browser-side plugin loading machinery — what is a plugin, how code arrives, and how hot reload rides on that model. This note owns the loading chain; the [web client architecture note](2026-07-19-gui-web-client-architecture.md) defers to it for loading and keeps owning slots, the data object layer, and the React face.
+> Scope: the browser-side plugin loading machinery — how code arrives, how Cordis governs it, and how hot reload rides on that model. This note owns the loading chain; the [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) owns package categories, build faces, shared-module requests, and npm dependency declarations, while the [web client architecture note](2026-07-19-gui-web-client-architecture.md) owns slots and the data object layer.
 
 ## Problem
 
@@ -24,31 +24,17 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
 
 ## Decision
 
-### Two package kinds; `dsh.client` means plugin, period
+### Package membership and module requests
 
-What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
+The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster.
 
-- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots and ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
-- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph, including infrastructure such as connection, runtime, ui-theme, i18n, hmr, and ui-renderer as well as feature packages such as ui-layout, ui-conversation, and ui-attachment.
-
-The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster.
-
-To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
-
-When does a plain package become a plugin? The upgrade law, recorded so the migration path stays honest: **a plain package becomes a plugin package when its consumers switch to cordis DI, not before.** Two promotions are queued: ui-slots (the slots machinery now living in runtime — SlotRegistry, the renderer contract, the root slot) and ui-primitives (once components are served through slots/services). Until then they stay plain, and their symbol exports stay ordinary static imports.
-
-Four edge rules govern imports across the two kinds. None of them depends on any per-package mark:
-
-- **Plugin ↔ plugin value imports are a build error.** This holds regardless of either side's `immediately` declaration — the rule must not depend on a mark someone can flip. Cooperation goes through cordis inject/services. `import type` is exempt; the type chain is untouched. This rule is why `scopeOf` is a `SessionRuntime` method and why `transportError` lives in `dsh-host-apiproxy`'s wire layer (its `RpcResult` home, inline-safe).
-- **Plugin → plain package value imports are externals**, judged against the platform list. That list is one constant in the shell (`platform.ts`: react family, cordis, ui-slots, ui-primitives), imported by both the tsdown preset (for the external judgement) and `seed.ts` (for the table warm-up). One constant, two consumers — the hand-sync drift class stays dead.
-- **The purity gate covers every plugin package.** Its three branches: platform imports become externals; INLINE_SAFE wire layers are inlined; any other workspace leak is a build error. The uniform bundle shape is what makes this coverage total — every plugin builds through the same preset, so no package can sit outside the gate.
-- **The shell is self-sufficient.** Apart from statically adopting the modules package that constructs the module system itself, the kernel value-imports no plugin package. Its loading and failure page uses plain DOM, local state, and local CSS fallbacks, so the fail-loud presentation does not depend on the render service whose failure it may report.
+The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module so the kernel can construct the module system. Runtime arrives through the same queue; static React, Cordis, and UI library identities come from the shell seed.
 
 ### One module system, one plugin governor
 
 The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an exports; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
 
-`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (the shell-adopted modules package) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (load the script and register its factory; concurrent calls share one in-flight task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
+`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. Import and prefetch recursively register declared dynamic requests before their consumer; a factory then materializes any registered-but-unmaterialized request synchronously. The table resolves through a fixed branch order: seed word → memoized record → static registration (the adopted modules bootstrap) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (register the requested dynamic factories and the row's own factory; concurrent arrivals share one task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
 
 The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
 
@@ -62,23 +48,23 @@ The shared tsdown preset emits `client.js.map` for every plugin and rewrites fir
 
 ### The loading flow, end to end
 
-What happens between `dsh web` starting and the UI appearing? Three stages: the host composes and serves a graph, the shell prefetches, then cordis orchestrates.
+What happens between `dsh web` starting and the UI appearing? Three stages: the host composes a graph and parser-preloads bootstrap factories, the shell claims the module system and prefetches, then Cordis orchestrates.
 
 **Host side — compose the graph.**
 
 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
-2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
+2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }] }`. The three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers and rejects synchronous request cycles. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
-**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. Infrastructure plugins including connection, runtime, ui-theme, i18n, ui-renderer, and hmr declare it; other UI plugins simply arrive on demand.
+**Phase one — the module face.** The injected HTML creates a handoff queue, executes the modules and runtime graph rows as blocking classic scripts, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel claims and materializes modules with a bootstrap `require` that rejects every external, constructs `ClientModuleSystem`, registers the same exports for the modules row, and lets the system adopt runtime's queued factory. It then prefetches every `immediately` row in parallel; prefetch recursively registers declared dynamic requests and the row itself without materializing either. A row's prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains an arrival mark, not a lifecycle barrier or package identity.
 
 **Phase two — the plugin face.**
 
 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
-2. It creates the statically adopted modules bootstrap entry and one entry per graph row. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
-3. Creation order carries no semantics; fibers activate through service waiting.
+2. It creates the modules entry from the adopted bootstrap exports and one entry per remaining graph row. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
+3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.
 
@@ -100,33 +86,19 @@ On the browser side, the driver reloads one plugin per frame, serialized:
 
 Every plugin shares this one semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber re-loads every dependent through cordis itself. Reloading connection or runtime cascades the whole UI — correct, if heavy.
 
-The support boundary, stated honestly. Reload is coarse by design: fresh fiber, fresh components, React state lost, data layer untouched — react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. Plain packages (react family, shell kernel, not-yet-promoted libraries) are not entries: changing them means a shell rebuild and a full page reload. No rollback in v1: an import failure leaves the entry fiberless and the next rebuilt frame retries from scratch; an apply failure leaves a FAILED fiber for the status projection; both log loudly. Self-reload works — the in-flight reload finishes in the old bundle's closure and the new apply opens a fresh SSE channel — but frames arriving in the gap are lost, and the next rebuild renotifies. One known dev-only race: a rebuilt frame overlapping a still-in-flight boot arrival shares that arrival's task and may materialize the pre-rebuild bytes; the next frame self-heals.
-
-## Package inventory (today → long term)
-
-| Package | Role | Today | Long term |
-|---|---|---|---|
-| react family / cordis | platform singletons | shell-bundled, seeded | plain forever (absolute base) |
-| vendored `@cordisjs/plugin-loader` | entry governance (same code both sides) | compile-time browserization, kernel-mounted | untouched (vendor policy) |
-| `dsh-client-modules` | the client module system | lazy CJS table; two-phase boot | plain forever (modules precede modules) |
-| `dsh-client-web` | module/Loader kernel + framework-free boot page | self-sufficient except for the statically adopted modules bootstrap | keeps shrinking |
-| `dsh-client-ui-renderer` | ctx↔React adapter + React root + slot-renderer assembly | dynamic plugin, declares `immediately` | owns hook binding and the application mount lifecycle |
-| `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery |
-| `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) |
-| `dsh-client-connection` | wire layer | plugin (`dsh.client` + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
-| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
-| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`; global CSS is in its client bundle | Theme Registry (separate ruling) |
-| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
-| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake |
-| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
+The support boundary, stated honestly. Reload is coarse by design: fresh fiber, fresh components, React state lost, data layer untouched — react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. Static assembly packages and the shell kernel are not entries: changing them means a shell rebuild and a full page reload. Reload has no rollback: an import failure leaves the entry fiberless and the next rebuilt frame retries from scratch; an apply failure leaves a FAILED fiber for the status projection; both log loudly. Self-reload works — the in-flight reload finishes in the old bundle's closure and the new apply opens a fresh SSE channel — but frames arriving in the gap are lost, and the next rebuild renotifies. One known dev-only race: a rebuilt frame overlapping a still-in-flight boot arrival shares that arrival's task and may materialize the pre-rebuild bytes; the next frame self-heals.
+
+## Package ownership
+
+The current package inventory and build forms live in the [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md). This note retains only the loading properties that apply to every dynamic row: lazy factory registration, Cordis entry governance, external-script arrival, source maps, and HMR.
 
 ## Consequences
 
-One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
+One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
 
-Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import exports until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
+Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
 
-Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
+Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
 
 ## Alternatives considered
 
@@ -137,7 +109,7 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
 | Reuse `@cordisjs/plugin-hmr` in the browser | ~80% solves problems the browser doesn't have (fs watching, deep graph coloring, Node's dual caches); the reload skeleton is copied as a shape |
 | Module federation | Independently built remote bundles are exactly the form vite federation does not support |
 | Import maps | Ruled out earlier; the DI require table is the terminal mechanism |
-| Full ctx-ification now (react and libraries via services, no module table) | The module-axis extreme; parked — the upgrade law walks there one package at a time instead |
-| Eager instantiation with a frozen table | Requires arrival-time ordering; lazy CJS registration makes recursive `require` self-ordering and matches the naive-puller phase split |
+| Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity |
+| Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests |
 | Fetch response text, then inject an inline `<script>` | Makes the module system buffer the complete source and maintain separate fetch/execute paths; dynamic source execution also breaks the browser-native association among the network resource, source map, and profile |
 | Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |

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

@@ -1,10 +1,10 @@
-# Agent Note: client 插件装载——普通包、dsh.client 插件与双阶段 boot
+# Agent Note: client 插件装载——惰性 factory、Cordis 生命周期与热重载
 
 Status: implemented
 
 [English](2026-07-23-client-plugin-loading-model.md) | 中文
 
-> 范围:浏览器侧的插件装载机件——什么是插件、代码怎么到达、热重载如何搭在这套模型上。装载链归本篇所有;[Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md) 在装载问题上以本篇为准,继续拥有 slot、数据对象层与 React 面
+> 范围:浏览器侧插件装载机件——代码如何到达、Cordis 如何治理代码,以及热重载如何搭乘这套模型。本 Note 拥有装载链;[client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.md)拥有包分类、构建 face、共享模块请求与 npm 依赖声明,[Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)则拥有 slot 与数据对象层
 
 ## Problem
 
@@ -24,31 +24,17 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 ## Decision
 
-### 两类包;`dsh.client` 即插件,别无他义
+### 包成员与模块请求
 
-什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别
+[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册
 
-- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots 与 ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
-- **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 编写的图里受治理的 entry,包括 connection、runtime、ui-theme、i18n、hmr、ui-renderer 等基础设施,也包括 ui-layout、ui-conversation、ui-attachment 等功能包。
-
-manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。
-
-新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。
-
-普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**两项升格在排队:ui-slots(现居 runtime 的 slots 机件——SlotRegistry、渲染器约定、root slot)与 ui-primitives(组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。
-
-四条边规则治理横跨两类包的 import。没有一条依赖任何单包标记:
-
-- **插件 ↔ 插件的值 import 是构建错误。**与两侧的 `immediately` 声明无关——规则不得依赖一个人人可翻转的标记。协作走 cordis inject/服务。`import type` 豁免;类型链分毫未动。这条规则正是 `scopeOf` 是 `SessionRuntime` 方法、`transportError` 住在 `dsh-host-apiproxy` wire 层(它的 `RpcResult` 老家,内联安全)的原因。
-- **插件 → 普通包的值 import 外置为 external**,按平台清单判定。清单是壳里的一个常量(`platform.ts`:react 家族、cordis、ui-slots、ui-primitives),tsdown 预设(external 判定)与 `seed.ts`(模块表预热)都 import 它。一个常量、两个消费方——人肉同步这一漂移缺陷类死透。
-- **纯度门禁覆盖每个插件包。**它的三条分支:平台 import 外置为 external;INLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。
-- **壳自足。**除了静态接纳负责构造模块系统自身的 modules 包,内核不对任何插件包执行值 import。加载与失败页面只使用原生 DOM、本地状态和本地 CSS 回退,因此大声失败的呈现不依赖它可能报告其失败的渲染服务。
+Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其普通 factory,让内核得以构造模块系统。Runtime 经同一 queue 到达;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
 
 ### 一套模块系统,一个插件治理器
 
 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
 
-`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(外壳接纳的 modules 包)→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(加载脚本、只登记工厂;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂与记录,下次到达即重新加载)。
+`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → 静态登记(已接纳的 modules bootstrap)→ 已登记 factory → graph row 外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃 factory 与记录,下次到达即重新加载)。
 
 vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
 
@@ -62,23 +48,23 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### 装载流程,端到端
 
-从 `dsh web` 启动到 UI 出现之间发生了什么?三个阶段:host 组合并供给一张图,壳预取,然后 cordis 编排。
+从 `dsh web` 启动到 UI 出现之间发生了什么?三个阶段:host 组合 graph 并由 parser 预载 bootstrap factory,外壳认领模块系统并预取,然后 Cordis 编排。
 
 **host 侧——组合这张图。**
 
 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
-2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
+2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }] }`。三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前,并拒绝同步请求环。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
-**第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。connection、runtime、ui-theme、i18n、ui-renderer、hmr 等基础设施插件声明它;其余 UI 插件则按需到达
+**第一阶段——模块面。**注入的 HTML 建立 handoff queue,以阻塞式 classic script 执行 modules 与 runtime graph row,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核用一个拒绝所有 external 的 bootstrap `require` 认领并物化 modules,构造 `ClientModuleSystem`,把同一 exports 登记给 modules row,再让系统接纳 queue 中的 runtime factory。随后它并行预取每个 `immediately` row;prefetch 会递归登记已声明的动态请求和 row 自身,但不物化任一项。单行预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是到达标记,不是生命周期屏障或包身份
 
 **第二阶段——插件面。**
 
 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
-2. 它创建静态接纳的 modules 启动 entry,并为图中每一行创建 entry。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host 图行;内核不追加组装伪 entry。
-3. 创建顺序不携带任何语义;fiber 经服务等待激活
+2. 它基于已接纳的 bootstrap exports 创建 modules entry,并为其余 graph row 各创建一个 entry。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
+3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
 
@@ -100,33 +86,19 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此换掉提供方的 fiber,每个依赖方都会经 cordis 本身重新装载。重载 connection 或 runtime 会级联整个 UI——正确,虽然重。
 
-支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑工厂」相冲突,属刻意不做。普通包(react 家族、壳内核、尚未升格的库)不是 entry:改它们意味着壳重建加整页刷新。v1 不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
-
-## 包盘点(现状 → 长期)
-
-| 包 | 角色 | 现状 | 长期 |
-|---|---|---|---|
-| react 家族 / cordis | 平台单例 | 打进壳,已播种 | 永为普通包(绝对基座) |
-| vendored `@cordisjs/plugin-loader` | entry 治理(两侧同一份代码) | 编译期浏览器化,内核挂载 | 不动(vendor 政策) |
-| `dsh-client-modules` | client 模块系统 | lazy CJS 模块表;双阶段 boot | 永为普通包(模块先于模块) |
-| `dsh-client-web` | 模块/Loader 内核 + 不依赖框架的启动页 | 除静态接纳 modules 启动项外保持自足 | 持续缩小 |
-| `dsh-client-ui-renderer` | ctx↔React 适配器 + React 根 + slot 渲染器组装 | 动态插件,声明 `immediately` | 持有钩子绑定与应用挂载生命周期 |
-| `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 |
-| `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) |
-| `dsh-client-connection` | wire 层 | 插件(dsh.client + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) |
-| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
-| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`;全局 CSS 位于其客户端 bundle | Theme Registry(另行裁定) |
-| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
-| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 |
-| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 |
+支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
+
+## 包归属
+
+当前包盘点与构建形态位于[client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.md)。本 Note 只保留适用于每个动态图 row 的装载属性:惰性 factory 登记、Cordis entry 治理、外部 script 到达、sourcemap 与 HMR。
 
 ## Consequences
 
-wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
+Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 导出;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在 graph 校验时被拦下;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
 
-名册:住在 web 组合包的配置树里(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE(Server-Sent Events)通道迁到 hmr 的 node 半。
+名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。
 
 ## Alternatives considered
 
@@ -137,7 +109,7 @@ wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块
 | 在浏览器复用 `@cordisjs/plugin-hmr` | 约 80% 在解决浏览器没有的问题(fs 监听、深度图着色、Node 的双缓存);只按形状抄用其重载骨架 |
 | 模块联邦(module federation) | 独立构建的远端 bundle 恰是 vite 联邦不支持的形态 |
 | import map | 早已排除;DI require 表是终局机制 |
-| 现在就彻底 ctx 化(react 与库全走服务,不设模块表) | 模块轴上的极端形态;搁置——升级法则让包一次一个地走向它 |
-| 冻结表 + 到达即实例化 | 要求按到达时刻排序;lazy CJS 登记让递归 `require` 自行定序,且与朴素拉取器的阶段拆分相合 |
+| 现在就彻底 ctx 化(React 与库全走服务,不设模块表) | 静态 UI 库仍暴露同步实体,因此删除模块表会让这些 import 失去共享身份 |
+| 冻结表 + 到达即实例化 | 会在 script 到达时执行 bundle 副作用;惰性登记把执行推迟到 Cordis import,并由递归 `require` 物化已登记请求 |
 | fetch 响应文本后注入内联 `<script>` | 模块系统必须缓冲整份源码并维护 fetch/execute 两条路径;动态源码执行也切断浏览器网络资源、sourcemap 与 profile 的原生关联 |
 | 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
+2026-08-15-client-shells-and-dynamic-packages.md: d9e2ddfaa169b12aa4bcd23b9ba215da463f8358
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 31cb4b88c4b161c52b1746756b6cabc6d089f63b

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

@@ -0,0 +1,86 @@
+# Agent Note: Client shell layering and dynamic package boundaries
+
+Status: implemented
+
+English | [中文](2026-08-15-client-shells-and-dynamic-packages.zh.md)
+
+> The [client plugin loading model](2026-07-23-client-plugin-loading-model.md) owns module arrival, Cordis lifecycle, and HMR. This note owns package placement, build faces, shared module requests, and npm dependency declarations; those decisions supersede the older package taxonomy and import-edge rules in the loading note.
+
+## Problem
+
+Client npm dependency sections describe installation and development relationships, but they do not reliably describe bundle contents. Treating `dependencies`, `peerDependencies`, or `devDependencies` as implicit bundler instructions can inline a shared React or workspace identity, or leave a built library carrying unresolved child imports without the host that is meant to assemble them.
+
+The browser application also contains distinct roles: the HTML/Vite compilation entry, the framework-free Cordis startup kernel, static assembly libraries, and Loader-governed plugins. Early execution from HTML is an arrival policy, not a package kind. Runtime and modules need to arrive before the Vite main module while retaining ordinary `lib/client.js` artifacts and dynamic graph rows.
+
+Shared UI libraries still expose synchronous TypeScript and React values to many consumers. Until those values move behind services or slots, making the libraries formal dynamic entries would preserve the value coupling while obscuring which module identity the shell must share.
+
+## Decision
+
+### Layers and build forms
+
+| Layer | Members | Responsibility | Build and load form |
+| --- | --- | --- | --- |
+| Web compilation shell | `apps/web` | Owns `index.html`, Vite configuration, dist chunks, and static assets | Assembles final browser output from built package exports |
+| Startup kernel | `packages/client/web` | Owns the plain-DOM boot page, module-system wiring, Cordis settlement, and renderer handoff | `staticLinked` `lib/index.js`; no `dsh.client` row |
+| Static assembly libraries | Cordis, `ui-primitives`, `ui-slots` | Supply shared module identities and direct value APIs | ESM `lib/index.js`, merged and chunked by Vite; not Loader entries |
+| Module bootstrap | `packages/client/modules` | Supplies the client module table and its Cordis wrapper | Dynamic package with one ordinary `lib/client.js`; the host delivers its factory early |
+| Dynamic client packages | runtime, `ui-renderer`, theme, and feature plugins | Participate through Cordis services, slots, and effects | Declare `dsh.client`, emit self-registering `lib/client.js`, and remain host-graph entries |
+
+`packages/client/web` keeps Cordis as matching peer and development dependencies and uses modules and static UI packages as development compilation inputs. `apps/web` consumes built package exports rather than aliases into workspace source.
+
+The `staticLinked` preset leaves every bare specifier as an external import in `lib/index.js` and emits relative CSS assets beside it. The Vite host resolves and deduplicates those imports and decides final chunk boundaries. A static library therefore does not copy the host's bundling policy into its own artifact.
+
+### Shared module requests
+
+Dynamic browser bundles implicitly externalize the common baseline: `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI identities, while `PRELOADED_CLIENT_EXTERNALS` names runtime's parser-preloaded dynamic identity. A package uses `dsh.client.external` only for an exact non-baseline value request. Type-only imports are erased and create no request; permitted third-party implementation libraries remain private bundle contents.
+
+A request has exactly two suppliers:
+
+1. The dynamic package row it names; a trailing `/client` aliases that package row.
+2. An exact key in the shell's static module table.
+
+There is no general `dsh.client.provide` alias mechanism. Dynamic rows and static keys exhaust the real suppliers, while Cordis service provision remains independent. Graph composition rejects malformed or missing requests, self-requests, and synchronous request cycles, and orders dynamic suppliers before their consumers. `ClientModuleSystem.import()` and `prefetch()` recursively register those dynamic supplier factories before the consumer can materialize, so network timing cannot violate the synchronous request graph.
+
+### Parser preloading and React handoff
+
+The modules Node half injects the startup protocol into the served HTML in this order:
+
+1. Create the `window.__ModuleLoader__` handoff queue.
+2. Execute the modules graph row's ordinary `lib/client.js` as a blocking classic script.
+3. Execute runtime's ordinary `lib/client.js` the same way.
+4. Assign `window.__DSH_BOOT__`.
+5. Execute the Vite main module.
+
+Both early scripts only register factories. The startup kernel claims the modules handoff and materializes it with a `require` function that rejects every external, constructs `ClientModuleSystem` from those exports, registers the same exports for the modules row, and lets the system drain runtime's queued factory. The modules client face consequently has a zero-runtime-external bootstrap requirement.
+
+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
+
+Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`. A dynamic package that imports, re-exports, augments, or names an internal dynamic package in `dsh.client.inject` keeps that package as matching peer and development dependencies. Static client inputs and React modules are development-only inputs for a dynamic package because the shell supplies their runtime identities.
+
+Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a private implementation, while a `staticLinked` library retains its bare import for the final host. Each build face decides externality independently from npm sections. Published file lists cover every runtime entry, relative asset, and declaration file reached by the artifact.
+
+`verify-client-packages` enforces these classifications, build forms, shared-module requests, publication closure, and module-graph acyclicity. Its `--fix` mode repairs only unambiguous manifest and build-config drift.
+
+## Alternatives considered
+
+**Convert every client package into a dynamic plugin immediately.** `ui-primitives` and `ui-slots` still provide synchronous values without independent service or slot lifecycles; a manifest declaration alone would not remove those imports.
+
+**Generate a separate `client-static.js` for modules or runtime.** Both packages remain dynamic graph rows and Cordis plugins; only their factory arrival is early. A second artifact would encode host policy in a filename and create two runtime products from one source.
+
+**Compile all shared modules into the Vite entry.** This would remove deployment composition and plugin-level replacement from business plugins, including the renderer and theme.
+
+**Retain a general module-provider declaration.** Package rows and exact static keys already name all suppliers; aliases would add another ownership protocol without a third supply source.
+
+**Hardcode preload URLs in `apps/web/index.html`.** URLs and `rev` values belong to the host's current graph. Rewriting the served HTML keeps the queue, bundle URLs, and manifest on one graph revision.
+
+## Consequences
+
+Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
+
+The startup protocol depends on the modules and runtime package ids, and modules must remain self-contained at runtime. Missing bootstrap handoffs fail 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.
+
+The two static UI libraries remain deliberate exceptions. Converting either one to a dynamic package requires moving all value consumers to services or slots and removing its identity from the static seed in the same change.

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

@@ -0,0 +1,86 @@
+# Agent Note: 客户端壳分层与动态包边界
+
+Status: implemented
+
+[English](2026-08-15-client-shells-and-dynamic-packages.md) | 中文
+
+> [Client 插件装载模型](2026-07-23-client-plugin-loading-model.md)负责模块到达、Cordis 生命周期和 HMR。本 Note 负责包归属、构建 face、共享模块请求及 npm 依赖声明;这些决定取代装载 Note 中较早的包分类和 import 边规则。
+
+## Problem
+
+Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundle 内容。把 `dependencies`、`peerDependencies` 或 `devDependencies` 当作隐式 bundler 指令,可能内联本应共享的 React 或 workspace 身份,也可能让构建后的库携带未解析子 import,却没有交给预期的宿主组装。
+
+浏览器应用还包含不同角色:HTML/Vite 编译入口、不依赖框架的 Cordis 启动内核、静态装配库,以及由 Loader 治理的插件。HTML 提前执行属于到达策略,不定义包类别。Runtime 和 modules 需要先于 Vite 主模块到达,同时继续使用普通 `lib/client.js` 产物和动态图 row。
+
+共享 UI 库仍向大量消费者暴露同步 TypeScript 与 React 实体。在这些实体进入 service 或 slot 前,形式上把库改为动态 entry 只会保留实体耦合,并模糊外壳必须共享的模块身份。
+
+## Decision
+
+### 分层与构建形态
+
+| 层 | 成员 | 职责 | 构建与加载形态 |
+| --- | --- | --- | --- |
+| Web 编译壳 | `apps/web` | 拥有 `index.html`、Vite 配置、dist chunk 和静态资源 | 从已构建 package export 组装最终浏览器产物 |
+| 启动内核 | `packages/client/web` | 拥有纯 DOM 启动页、模块系统接线、Cordis settle 和 renderer handoff | `staticLinked` `lib/index.js`;无 `dsh.client` row |
+| 静态装配库 | Cordis、`ui-primitives`、`ui-slots` | 提供共享模块身份和直接实体 API | ESM `lib/index.js`,由 Vite 合并拆分;不是 Loader entry |
+| 模块自举包 | `packages/client/modules` | 提供 client 模块表及其 Cordis wrapper | 带一个普通 `lib/client.js` 的动态包;host 提前送达其 factory |
+| 动态 client 包 | runtime、`ui-renderer`、主题和功能插件 | 通过 Cordis service、slot 和 effect 参与应用 | 声明 `dsh.client`,产出自注册 `lib/client.js`,并保留 host graph entry |
+
+`packages/client/web` 把 Cordis 保持为 matching peer 与开发依赖,并把 modules 和静态 UI 包作为开发期编译输入。`apps/web` 消费已构建 package export,不通过 alias 读取 workspace 源码。
+
+`staticLinked` 预设让 `lib/index.js` 中每个 bare specifier 保持 external import,并在旁边输出相对 CSS 资产。Vite 宿主负责解析和去重这些 import,并决定最终 chunk 边界。静态库不会把宿主打包策略复制进自身产物。
+
+### 共享模块请求
+
+动态浏览器 bundle 会隐式 external 统一基座:`PLATFORM_MODULES` 命名由外壳播种的 React、Cordis 和静态 UI 身份,`PRELOADED_CLIENT_EXTERNALS` 命名由 HTML parser 预载的 runtime 动态身份。包只在精确请求基座外实体时使用 `dsh.client.external`。纯类型 import 会被擦除,不产生请求;允许的第三方实现库保留为 bundle 私有内容。
+
+请求只有两种提供方:
+
+1. 请求所命名的 dynamic package row;末尾 `/client` 会别名到该 package row。
+2. 外壳静态模块表中的精确 key。
+
+不存在通用 `dsh.client.provide` 别名机制。动态 row 和静态 key 已穷尽实际提供方,Cordis service provide 与此相互独立。图组合会拒绝畸形或缺失请求、自请求和同步请求环,并把动态提供方排在消费者之前。`ClientModuleSystem.import()` 与 `prefetch()` 会在消费者能够物化前递归登记这些动态提供方的 factory,因此网络时序无法破坏同步请求图。
+
+### Parser 预载与 React 移交
+
+Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
+
+1. 建立 `window.__ModuleLoader__` handoff queue。
+2. 以阻塞式 classic script 执行 modules graph row 的普通 `lib/client.js`。
+3. 以相同方式执行 runtime 的普通 `lib/client.js`。
+4. 赋值 `window.__DSH_BOOT__`。
+5. 执行 Vite 主模块。
+
+两个提前执行的脚本都只注册 factory。启动内核认领 modules handoff,用一个拒绝所有 external 的 `require` 函数物化它,使用导出的类构造 `ClientModuleSystem`,把同一导出注册给 modules row,再由模块系统排空 queue 中的 runtime factory。因此 modules client face 必须满足零 runtime external 的自举要求。
+
+`immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
+
+### 依赖声明
+
+每个 client 包都把 Cordis 保持为 matching `peerDependencies` 和 `devDependencies`。动态包若 import、re-export、augment 内部动态包,或在 `dsh.client.inject` 中命名它,就把该包保持为 matching peer 与开发依赖。静态 client 输入和 React 模块对动态包只是开发依赖,因为外壳提供其运行期身份。
+
+普通安装库仍放在 `dependencies`:动态构建可以内联私有实现,而 `staticLinked` 库会保留 bare import 交给最终宿主。各构建 face 独立决定 external,不由 npm 区段推导。发布文件列表覆盖产物实际可达的每个运行期入口、相对资产和声明文件。
+
+`verify-client-packages` 会检查这些分类、构建形态、共享模块请求、发布闭包和模块图无环性。其 `--fix` 模式只修复无歧义的 manifest 与构建配置漂移。
+
+## Alternatives considered
+
+**立即把所有 client 包改为动态插件。** `ui-primitives` 与 `ui-slots` 仍提供同步实体,且没有独立 service 或 slot 生命周期;只加 manifest 声明不会移除这些 import。
+
+**为 modules 或 runtime 生成单独的 `client-static.js`。** 两个包仍是动态图 row 和 Cordis 插件,只有 factory 提前到达。第二份产物会把宿主策略编码进文件名,并让同一源码产生两个运行期产品。
+
+**把全部共享模块编进 Vite entry。** 这会让业务插件失去部署组合与插件级替换能力,包括 renderer 和主题。
+
+**保留通用模块 provider 声明。** Package row 和精确静态 key 已命名全部提供方;别名会增加另一套归属协议,却没有第三种供给来源。
+
+**在 `apps/web/index.html` 中硬编码预载 URL。** URL 与 `rev` 属于 host 当前 graph。改写实际返回的 HTML 才能让 queue、bundle URL 和 manifest 使用同一 graph revision。
+
+## Consequences
+
+Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
+
+启动协议依赖 modules 和 runtime 的 package id,modules 还必须保持运行期自包含。缺少 bootstrap handoff 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
+
+外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
+
+两个静态 UI 库仍是明确例外。把其中任一项转换为动态包时,必须在同一变更中把全部实体消费者迁移到 service 或 slot,并从静态 seed 删除对应身份。

+ 20 - 2
apps/web/tests/assembled-boot.ts

@@ -11,11 +11,14 @@ import { readFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { act, cleanup } from '@testing-library/react'
 import { afterEach, beforeEach, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import type {
+  ClientModuleHandoffTarget, ClientPluginHandoff, WebBootEntry,
+} from '@deepseek-ai/dsh-client-modules/client'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 /** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
 const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
+  { id: '@deepseek-ai/dsh-client-modules', bundlePath: 'packages/client/modules/lib/client.js', url: '/plugins/modules.js', rev: 'fx', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
   { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
@@ -56,7 +59,7 @@ const bundles = new Map(PLUGINS.map(plugin => [
 
 interface FixtureWindow extends Window {
   __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
+  __ModuleLoader__?: ClientModuleHandoffTarget
 }
 
 class ResizeObserverStub {
@@ -120,6 +123,21 @@ export function mountAssembledApp(): void {
   root.id = 'root'
   document.body.appendChild(root)
   win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
+  const handoffs: ClientPluginHandoff[] = []
+  win.__ModuleLoader__ = {
+    mode: 'queue',
+    handoffs,
+    load(handoff) { handoffs.push(handoff) },
+  }
+  // Mirror the blocking Host-injected scripts: the kernel claims modules,
+  // then the resulting module system adopts runtime from the same queue.
+  for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
+    const plugin = PLUGINS.find(candidate => candidate.id === id)
+    if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`)
+    const code = bundles.get(plugin.url)
+    if (code === undefined) throw new Error(`missing built bundle ${plugin.url}`)
+    ;(0, eval)(code)
+  }
   act(() => {
     const entry = new AppWebEntry(root, {
       loadBundle: async (url) => {

+ 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: c2002b847cd15560ca4493ce6e8623667c395360
-client-modules.zh.md: 88be252a7fdff6bca7e2d8eb4e37b9e1a75dda98
+client-modules.md: 7a7261f32d68099f298e4b35991516b993771e39
+client-modules.zh.md: 2eb6fc8e99926c235db4cc8d0c27d7c825973324

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

@@ -16,7 +16,9 @@ The graph is the wire single source between the Node and browser halves: the hos
  * single source: the host node half (package root) produces this same shape.
  * `immediately` marks stage-one prefetch; `inject` is informational graph
  * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation).
+ * declaration and reach fibers through entry creation). `external` carries
+ * module-graph edges: unlike `inject`, they constrain code arrival because
+ * `require` is synchronous (see {@link WebBootGraph.entries}).
  */
 interface WebBootEntry {
   /** Entry name == package name. */
@@ -29,6 +31,8 @@ interface WebBootEntry {
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean
+  /** Non-baseline module specifiers this row requests; omitted when it requests none. */
+  external?: string[]
 }
 ```
 
@@ -37,7 +41,11 @@ interface WebBootEntry {
 interface WebBootGraph {
   /** Consistency anchor over the whole graph (content + bundle hashes). */
   rev: string
-  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
+  /**
+   * Composed entries in module-graph order — a dynamic package row precedes
+   * rows whose `external` requests that package. Cordis activation order is
+   * unrelated and remains owned by fiber service waiting.
+   */
   entries: WebBootEntry[]
 }
 ```
@@ -114,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
 onGraphChanged(listener: () => void): () => void
 ```
 
-Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:272`](../../packages/client/modules/src/index.ts)
 <!-- END GENERATED cordis-surface -->

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

@@ -16,7 +16,9 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 
  * single source: the host node half (package root) produces this same shape.
  * `immediately` marks stage-one prefetch; `inject` is informational graph
  * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation).
+ * declaration and reach fibers through entry creation). `external` carries
+ * module-graph edges: unlike `inject`, they constrain code arrival because
+ * `require` is synchronous (see {@link WebBootGraph.entries}).
  */
 interface WebBootEntry {
   /** Entry name == package name. */
@@ -29,6 +31,8 @@ interface WebBootEntry {
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean
+  /** Non-baseline module specifiers this row requests; omitted when it requests none. */
+  external?: string[]
 }
 ```
 
@@ -37,7 +41,11 @@ interface WebBootEntry {
 interface WebBootGraph {
   /** Consistency anchor over the whole graph (content + bundle hashes). */
   rev: string
-  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
+  /**
+   * Composed entries in module-graph order — a dynamic package row precedes
+   * rows whose `external` requests that package. Cordis activation order is
+   * unrelated and remains owned by fiber service waiting.
+   */
   entries: WebBootEntry[]
 }
 ```
@@ -114,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
 onGraphChanged(listener: () => void): () => void
 ```
 
-Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:272`](../../packages/client/modules/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
packages/client/AGENTS.md

@@ -88,7 +88,7 @@ Three declarations read like dependency edges and none is interchangeable: Cordi
 | Who may satisfy it | any plugin providing that service, replaceable | the single module identity, not replaceable |
 | Cycles | allowed | rejected |
 
-The seam is `loader.internal = modules`: cordis reaches plugin code through `EntryTree.import`, so every module request must be satisfiable before cordis can order activation above it. Script-tag order is therefore the module graph's topological order — providers before consumers — computed by the modules node half and injected by the host. The two orders can run opposite: a provider that injects services loads its script first and activates last.
+The seam is `loader.internal = modules`: cordis reaches plugin code through `EntryTree.import`, so every module request must be satisfiable before cordis can order activation above it. The modules node half emits rows in topological order, and `ClientModuleSystem.import`/`prefetch` recursively registers dynamic provider factories before their consumers materialize. This module order is independent from Cordis activation: a provider that injects services can register first and activate last.
 
 `packages/client/web` is not a Loader entry. Its static imports seed `PLATFORM_MODULES`; parser-preloaded dynamic rows remain ordinary Loader entries and ordinary `lib/client.js` artifacts.
 

+ 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: efaff699839b977cc45f89f3c164402241b90dc2
-README.zh.md: 6ab3c8494d7ea902e8b891008f236aec41301a35
+README.md: f9f1dcf5b7928bdf18e581826a85b47e7c59bf05
+README.zh.md: eb6aaf194592c8fb232cc795aae6de82f4743d00

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

@@ -4,11 +4,13 @@ English | [中文](README.zh.md)
 
 Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` through its `internal` contract — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
 
-Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
+Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively; graph composition places declared dynamic requests before their consumers, and require cycles throw because factory-form CJS cannot deliver partial exports. `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
 
-Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
+Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; bootstrap static registry (`registerStatic`, used for modules itself) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
 
-The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, carries package-specific `dsh.client.external` requests, orders dynamic providers before consumers, and serves each bundle with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+
+`dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline: shell-seeded React, Cordis, and static UI libraries plus parser-preloaded runtime. A request is answered by the dynamic package row it names or an exact static-table key; only a trailing `/client` aliases a package row, and there is no provider-alias declaration. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles; import and prefetch recursively register dynamic suppliers before their consumers materialize. See [shared modules and the module graph](../AGENTS.md#shared-modules-and-the-module-graph).
 
 ## Model Experience
 

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

@@ -4,11 +4,13 @@
 
 客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并通过其 `internal` 约定注入该包的 `ClientModuleLoader`;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
 
-惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
+惰性 CJS 模型(web2):执行插件 bundle 只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它;图组合会把声明的动态请求提供方放在消费者之前,而 require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。`<id>/client` 与裸 id 指向同一表层(一个插件 bundle 就是其包的客户端侧)。
 
-解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
+解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;自举静态注册表(`registerStatic`,仅 modules 自身使用)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载 classic script + 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
 
-Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
+Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的 bundle 哈希和包专属 `dsh.client.external` 请求写入启动图,把动态提供方排在消费者之前,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
+
+`dsh.client.external` 是统一基座之外的可选精确 specifier 请求列表。统一基座包括外壳播种的 React、Cordis 和静态 UI 库,以及由 HTML parser 预载的 runtime。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
 
 ## 模型体验
 

+ 3 - 2
packages/client/modules/src/client/manifest.ts

@@ -279,8 +279,9 @@ export interface ClientModuleLoader {
    */
   registerStatic(id: string, module: unknown): void
   /**
-   * Stage-one arrival: load the entry's script to register its factory (no
-   * materialization — module side effects wait for import).
+   * Stage-one arrival: load the entry's declared dynamic requests, then its
+   * own script, to register their factories (no materialization — module side
+   * effects wait for import).
    * No-op for bootstrap-registered ids and ids whose factory is already
    * registered; concurrent calls share one in-flight task. To force a fresh
    * load (HMR), {@link invalidate} first.

+ 26 - 10
packages/client/modules/src/client/system.ts

@@ -114,6 +114,23 @@ export class ClientModuleSystem implements ClientModuleLoader {
     return task
   }
 
+  /** Register every dynamic request before registering its consumer. */
+  private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise<void> {
+    const cycleStart = open.indexOf(row.id)
+    if (cycleStart !== -1) {
+      throw new Error(
+        `client-modules: module arrival cycle ${[...open.slice(cycleStart), row.id].join(' -> ')} `
+        + '(the host must reject this graph before serving it)',
+      )
+    }
+    const next = [...open, row.id]
+    for (const request of row.external) {
+      const dependency = this.graphRows.get(stripClientSuffix(request))
+      if (dependency !== undefined) await this.arriveGraphRow(dependency, next)
+    }
+    await this.arrive(row)
+  }
+
   /** Materialize a registered factory (synchronous; memoized in loadCache). */
   private materialize(id: string): ClientModuleRecord {
     const existing = this.loadCache.get(id)
@@ -167,15 +184,14 @@ export class ClientModuleSystem implements ClientModuleLoader {
       this.loadCache.set(specifier, { id: specifier, exports, styles: [], edges: new Set() })
       return exports
     }
-    if (!this.factories.has(specifier)) {
-      const row = this.graphRows.get(specifier)
-      if (row === undefined) {
-        throw new Error(
-          `client-modules: cannot resolve "${specifier}" — not a seed word, not a bootstrap module, `
-          + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
-        )
-      }
-      await this.arrive(row)
+    const row = this.graphRows.get(specifier)
+    if (row !== undefined) {
+      await this.arriveGraphRow(row)
+    } else if (!this.factories.has(specifier)) {
+      throw new Error(
+        `client-modules: cannot resolve "${specifier}" — not a seed word, not a bootstrap module, `
+        + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
+      )
     }
     return this.materialize(specifier).exports
   }
@@ -189,7 +205,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
     if (this.statics.has(id)) return
     const row = this.graphRows.get(id)
     if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
-    await this.arrive(row)
+    await this.arriveGraphRow(row)
   }
 
   invalidate(id: string): void {

+ 68 - 2
packages/client/modules/tests/loader.client.spec.ts

@@ -9,7 +9,7 @@
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
-  ClientModuleSystem,
+  ClientModuleSystem, parseBootManifest,
   type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
 } from '../src/client/index.ts'
 
@@ -23,7 +23,8 @@ afterEach(() => {
   for (const el of document.querySelectorAll('style, script')) el.remove()
 })
 
-const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
+const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
+  ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', external: [], ...fields })
 
 interface Bench {
   loader: ClientModuleLoader
@@ -61,6 +62,20 @@ function bench(
 }
 
 describe('lazy CJS arrival', () => {
+  it('adopts handoffs queued by parser-blocking preload scripts', async () => {
+    const handoffs: ClientPluginHandoff[] = [{ id: 'runtime', factory: () => ({ marker: 'preloaded' }) }]
+    win.__ModuleLoader__ = {
+      mode: 'queue',
+      handoffs,
+      load: (handoff) => { handoffs.push(handoff) },
+    }
+    const b = bench([row('runtime')])
+    const exports = await b.loader.import('runtime', '', {})
+    expect((exports as { marker: string }).marker).toBe('preloaded')
+    expect(b.fetched).toEqual([])
+    expect(win.__ModuleLoader__?.mode).toBe('live')
+  })
+
   it('prefetch loads and registers but does not run the factory', async () => {
     const ran: string[] = []
     const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
@@ -88,6 +103,26 @@ describe('lazy CJS arrival', () => {
     expect(b.fetched).toHaveLength(1)
   })
 
+  it('registers declared dynamic requests before materializing their consumer', async () => {
+    const b = bench([
+      row('consumer', { external: ['provider/client', 'react'] }),
+      row('provider'),
+    ], {
+      consumer: req => ({ provider: req('provider/client'), react: req('react') }),
+      provider: () => ({ marker: 'provider' }),
+    }, { seed: { react: { marker: 'react' } } })
+    const exports = await b.loader.import('consumer', '', {}) as {
+      provider: { marker: string }
+      react: { marker: string }
+    }
+    expect(b.fetched).toEqual([
+      '/plugins/provider/client.js?rev=0',
+      '/plugins/consumer/client.js?rev=0',
+    ])
+    expect(exports.provider.marker).toBe('provider')
+    expect(exports.react.marker).toBe('react')
+  })
+
   it('concurrent callers share one in-flight arrival and materialize once', async () => {
     const ran: string[] = []
     const url = '/plugins/a/client.js?rev=0'
@@ -216,6 +251,14 @@ describe('failure modes', () => {
     expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
   })
 
+  it('a module arrival cycle is loud even if a malformed host graph reaches the browser', async () => {
+    const b = bench([
+      row('a', { external: ['b'] }),
+      row('b', { external: ['a'] }),
+    ])
+    await expect(b.loader.prefetch('a')).rejects.toThrow('module arrival cycle a -> b -> a')
+  })
+
   it('double boot is loud', () => {
     bench([])
     expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
@@ -223,6 +266,29 @@ describe('failure modes', () => {
   })
 })
 
+describe('boot manifest wire', () => {
+  it('normalizes absent shared-module fields and carries the declared ones', () => {
+    const manifest = parseBootManifest({
+      rev: 'graph',
+      entries: [
+        { id: 'a', url: '/plugins/a/client.js', rev: '1' },
+        { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
+      ],
+    })
+    expect(manifest.modules).toEqual([
+      { id: 'a', url: '/plugins/a/client.js', rev: '1', external: [] },
+      { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
+    ])
+  })
+
+  it('rejects a non-array external', () => {
+    expect(() => parseBootManifest({
+      rev: 'graph',
+      entries: [{ id: 'a', url: '/a', rev: '1', external: 'react' }],
+    })).toThrow('client-modules: boot manifest entry "a" external must be a string array')
+  })
+})
+
 describe('HMR reset', () => {
   it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
     let generation = 0

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

@@ -8,7 +8,8 @@ import { pathToFileURL } from 'node:url'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
-import { ClientModuleRegistry } from '../src/index.ts'
+import { ClientModuleRegistry, orderByModuleGraph } from '../src/index.ts'
+import type { WebBootEntry } from '../src/index.ts'
 
 let root: string | undefined
 
@@ -37,6 +38,13 @@ function writePackage(
   return clientPath
 }
 
+/** Create a built package with the supplied client declaration. */
+function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
+  const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
+  mkdirSync(dirname(clientPath), { recursive: true })
+  writeFileSync(clientPath, 'module.exports = {}\n')
+}
+
 /** Construct the node-half service and capture its plugin-bundle route. */
 function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
   const ctx = new Context()
@@ -150,3 +158,95 @@ describe('client bundle activation', () => {
     expect(body).toBe(map)
   })
 })
+
+describe('shared module declarations', () => {
+  it('accepts external requests and carries them onto the graph row', () => {
+    const packageName = '@fixture/shared-declared'
+    writeBuiltPackage(packageName, { external: ['react'] })
+    expect(construct([packageName]).graph().entries).toEqual([{
+      id: packageName,
+      url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
+      rev: expect.any(String) as unknown as string,
+      external: ['react'],
+    }])
+  })
+
+  it('omits external when the package declares no requests', () => {
+    const packageName = '@fixture/shared-absent'
+    writeBuiltPackage(packageName, {})
+    const [row] = construct([packageName]).graph().entries
+    expect(row).not.toHaveProperty('external')
+  })
+
+  it('rejects a non-array external', () => {
+    const packageName = '@fixture/external-not-array'
+    writeBuiltPackage(packageName, { external: 'react' })
+    expect(() => construct([packageName]))
+      .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
+  })
+})
+
+describe('module graph order', () => {
+  const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
+    ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
+  const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
+
+  it('places every requested package row before its consumers along a chain', () => {
+    expect(ids(orderByModuleGraph([
+      entry('ui', { external: ['slots'] }),
+      entry('slots', { external: ['render'] }),
+      entry('render'),
+    ]))).toEqual(['render', 'slots', 'ui'])
+  })
+
+  it('places a shared package row before both arms of a diamond', () => {
+    expect(ids(orderByModuleGraph([
+      entry('app', { external: ['left', 'right'] }),
+      entry('left', { external: ['vendor'] }),
+      entry('right', { external: ['vendor'] }),
+      entry('vendor'),
+    ]))).toEqual(['vendor', 'left', 'right', 'app'])
+  })
+
+  it('resolves a /client request onto the requested package row', () => {
+    expect(ids(orderByModuleGraph([
+      entry('ui', { external: ['runtime/client'] }),
+      entry('runtime'),
+    ]))).toEqual(['runtime', 'ui'])
+  })
+
+  it('leaves a request no row answers to the static assembly channel', () => {
+    expect(ids(orderByModuleGraph([
+      entry('consumer', { external: ['@deepseek-ai/cordis'] }),
+      entry('other'),
+    ]))).toEqual(['consumer', 'other'])
+  })
+
+  it('rejects a cycle and names the packages on it', () => {
+    expect(() => orderByModuleGraph([
+      entry('a', { external: ['b'] }),
+      entry('b', { external: ['a'] }),
+    ])).toThrow('client-modules: module graph cycle a -> b -> a')
+  })
+
+  it('rejects a row requesting its own package name', () => {
+    expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
+      .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
+  })
+
+  it('composes the served graph in module-graph order', () => {
+    const consumerName = '@fixture/order-consumer'
+    const dependencyName = '@fixture/order-dependency'
+    writeBuiltPackage(consumerName, { external: [dependencyName] })
+    writeBuiltPackage(dependencyName, {})
+    expect(ids(construct([consumerName, dependencyName]).graph().entries))
+      .toEqual([dependencyName, consumerName])
+  })
+
+  it('fails activation loud when scanned packages form a module cycle', () => {
+    writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
+    writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
+    expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
+      .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
+  })
+})

+ 2 - 2
packages/client/web/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/web/README.md
-README.md: 434091ab5deb10c6f900a9b16208cf061bd92b17
-README.zh.md: 3ec28efc9b291be4df84f2d5f32ca65804b52115
+README.md: 705c04b8499465a44ed76fc9acaa2648b1a09362
+README.zh.md: 141236df4d6a606b1d7d62e3160bfa2392548829

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

@@ -2,11 +2,11 @@
 
 English | [中文](README.zh.md)
 
-Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage builds `@deepseek-ai/dsh-client-modules` over the host-provided `window.__DSH_BOOT__` graph and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The host graph owns the roster and prefetch marks; this package adds only the statically adopted modules bootstrap entry.
+Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage claims the parser-preloaded `@deepseek-ai/dsh-client-modules` factory, builds its module system over the host-provided `window.__DSH_BOOT__` graph, adopts the queued runtime factory, and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The host graph owns the roster, parser preloads, and prefetch marks; this package adds only the adopted modules bootstrap entry.
 
-The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
+The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). Modules is the only package registered through `registerStatic`: its ordinary `lib/client.js` remains the graph row's artifact, but the kernel must claim and materialize that factory before the module system can load anything.
 
-`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shared modules: seed-table keys, tsdown client externals, and the Vite alias set are its projections.
+`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shell-seeded shared modules. Together with `PRELOADED_CLIENT_EXTERNALS`, it defines the implicit external baseline for every dynamic bundle; `dsh.client.external` adds only exact non-baseline requests.
 
 The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it.
 

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

@@ -2,11 +2,11 @@
 
 [English](README.md) | 中文
 
-Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段基于宿主提供的 `window.__DSH_BOOT__` 图构建 `@deepseek-ai/dsh-client-modules`,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。名册与预取标记归宿主图所有;本包只额外加入静态接纳的 modules 启动 entry。
+Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段认领 HTML parser 预载的 `@deepseek-ai/dsh-client-modules` factory,基于宿主提供的 `window.__DSH_BOOT__` 图构建模块系统,接纳 queue 中的 runtime factory,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。名册、parser 预载项和预取标记归宿主图所有;本包只额外加入已接纳的 modules 启动 entry。
 
-启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身
+启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 是唯一通过 `registerStatic` 注册的包:普通 `lib/client.js` 仍是其 graph row 产物,但内核必须在模块系统能够加载任何内容前认领并物化该 factory
 
-`PLATFORM_MODULES`(src/platform.ts)是共享模块接口的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影
+`PLATFORM_MODULES`(src/platform.ts)是外壳播种共享模块的唯一事实来源。它与 `PRELOADED_CLIENT_EXTERNALS` 一起定义全部动态 bundle 的隐式 external 基座;`dsh.client.external` 只添加基座之外的精确请求
 
 可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。
 

+ 44 - 8
packages/client/web/src/boot.ts

@@ -6,10 +6,8 @@
  */
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
-import {
-  ClientModuleSystem, parseBootManifest,
-  type BootManifest, type ClientModuleSystemOptions, type DshWindow,
+import type {
+  BootManifest, ClientModuleSystem, ClientModuleSystemOptions, DshWindow,
 } from '@deepseek-ai/dsh-client-modules/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { BootPage } from './boot-page.ts'
@@ -20,9 +18,45 @@ import './base.css'
 /** Module transport hook replaced by jsdom tests. */
 export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
 
+/** Runtime exports materialized from the parser-preloaded modules bundle. */
+type ModulesClientExports = typeof import('@deepseek-ai/dsh-client-modules/client')
+
 /** Statically adopted bootstrap package that constructs the client module system. */
 const MODULES_ID = '@deepseek-ai/dsh-client-modules'
 
+/**
+ * Claim and materialize the modules handoff before the module system exists.
+ * The bootstrap bundle must be self-contained because no module table can
+ * answer a runtime external yet.
+ * @param win - Browser boot globals populated by the host HTML transform.
+ * @returns The modules package's browser exports.
+ */
+function claimModulesClient(win: DshWindow): ModulesClientExports {
+  const queue = win.__ModuleLoader__
+  if (queue === undefined || queue.mode !== 'queue') {
+    throw new Error('web boot: window.__ModuleLoader__ bootstrap queue is missing')
+  }
+  const index = queue.handoffs.findIndex(handoff => handoff.id === MODULES_ID)
+  const handoff = queue.handoffs[index]
+  if (handoff === undefined) {
+    throw new Error(`web boot: HTML did not preload ${MODULES_ID}/client.js`)
+  }
+  queue.handoffs.splice(index, 1)
+  const exports = handoff.factory((specifier) => {
+    throw new Error(
+      `web boot: ${MODULES_ID}/client.js requested external "${specifier}" before the module system existed`,
+    )
+  })
+  if (
+    typeof exports.ClientModuleSystem !== 'function'
+    || typeof exports.parseBootManifest !== 'function'
+    || typeof exports.apply !== 'function'
+  ) {
+    throw new Error(`web boot: ${MODULES_ID}/client.js did not export the bootstrap module face`)
+  }
+  return exports as unknown as ModulesClientExports
+}
+
 /** Browser boot entry consumed by `apps/web`. */
 export class AppWebEntry {
   private readonly container: HTMLElement
@@ -49,14 +83,16 @@ export class AppWebEntry {
    * @returns Resolves after application mount or failure rendering.
    */
   async run(): Promise<void> {
-    this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
-    this.modules = new ClientModuleSystem({
+    const win = globalThis as DshWindow
+    const modulesClient = claimModulesClient(win)
+    this.manifest = modulesClient.parseBootManifest(win.__DSH_BOOT__)
+    this.modules = new modulesClient.ClientModuleSystem({
       modules: this.manifest.modules,
       staticModules: getStaticModules(),
       ...this.seams,
     })
-    this.modules.registerStatic(MODULES_ID, ModulesClient)
-    ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
+    this.modules.registerStatic(MODULES_ID, modulesClient)
+    win.__DSH_MODULES__ = this.modules
 
     const prefetching = this.prefetchImmediateTier()
     const ctx = new Context()

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

@@ -4541,7 +4541,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'WebBootEntry',
-    declaration: 'export interface WebBootEntry {\n    id: string;\n    url: string;\n    rev: string;\n    inject?: string[];\n    immediately?: boolean;\n}',
+    declaration: 'export interface WebBootEntry {\n    id: string;\n    url: string;\n    rev: string;\n    inject?: string[];\n    immediately?: boolean;\n    external?: string[];\n}',
   },
   {
     name: 'WebBootGraph',

+ 0 - 1
scripts/verify-package-readme-model-experience.ts

@@ -95,7 +95,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
   'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
-  'packages/client/render-service': { kind: 'none', reason: 'Browser-side UI assembly layer; registers nothing model-facing.' },
   'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
   'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
   'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },

+ 0 - 3
tsconfig.base.json

@@ -220,9 +220,6 @@
       "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
       "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
       "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
-      "@deepseek-ai/dsh-client-render-service": ["./packages/client/render-service/src"],
-      "@deepseek-ai/dsh-client-render-service/client": ["./packages/client/render-service/src/client"],
-      "@deepseek-ai/dsh-client-render-service/invariant": ["./packages/client/render-service/src/invariant.ts"],
       // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes,
       // so the generic wildcard cannot map these three package names.
       "@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"],

+ 0 - 4
vitest.config.ts

@@ -199,10 +199,6 @@ export default defineConfig({
         'packages/client/ui-slots/src/*',
         'packages/client/ui-layout/src/*',
         'packages/client/web/src/*',
-        // The render service's node half and invariant companion are the empty
-        // dual-face pair every client plugin carries; its browser half is
-        // covered by this package's specs.
-        'packages/client/render-service/src/*',
         'packages/host/webserver/src/*',
         'packages/client/modules/src/client/system.ts',
         'packages/client/hmr/src/client/index.ts',