Bladeren bron

refactor(gui): rebuild the client loading kernel as dsh-client-modules with a two-phase boot

The module system moves out of dsh-client-runtime (./loader retired) into
its own package: a lazy CJS table where executing a bundle only registers
its factory and materialization happens at first require, memoized, with
recursive requires self-ordering. ClientModuleSystem is a class; index.ts
keeps the types and a thin factory. Boot is two-phase: phase one prefetches
the immediately tier in parallel (registration only, failures deferred to
phase two's loud import); phase two mounts the vendored Loader with the
module system as internal, creates one entry per graph row plus the
app-shell pseudo-row the kernel appends itself, and settles on an
all-ACTIVE sweep. The shell kernel is self-sufficient: hand-rolled
loader-status stores, no plugin value imports, platform seed list single-
sourced in platform.ts.
imccyu 1 maand geleden
bovenliggende
commit
b58f0989f9

+ 16 - 0
apps/web/src/node-module-stub.ts

@@ -0,0 +1,16 @@
+/**
+ * Browser stand-in for `node:module`, mapped by the vite alias in
+ * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
+ * `createRequire` at module scope but only calls it inside
+ * `ModuleLoader.fromInternal()`, whose version probe is compiled to the
+ * `"0.0.0"` define in the browser build — so this throw is a fail-loud
+ * tripwire for any path that would genuinely need Node's module machinery.
+ */
+
+/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
+export const createRequire = (): never => {
+  throw new Error('node:module is not available in the browser')
+}
+
+/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
+export type LoadHookContext = never

+ 64 - 38
apps/web/tests/smoke-fixture.e2e.ts

@@ -1,41 +1,67 @@
-// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
-// registry surface + __DSH_BOOT__ injection + built shell dist in a real
-// chromium. First describe: manifest injection + static serving. Second
+// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
+// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
+// chromium. First describe: graph injection + the fail-loud half. Second
 // describe: the settled success pass — all nine REAL tsdown bundles load
-// through the DI chain in ?fixture mode, the three-column frame appears in
-// one flip, and the resident question completes through the real UI stack.
-// The full model round lands in smoke-real under the W5 real-host standard.
+// through the module system + vendored Loader chain in ?fixture mode (the
+// infrastructure four ride the immediately prefetch tier, the UI rows fetch
+// on demand), the three-column frame appears in one flip, and the resident
+// question completes through the real UI stack. The full model round lands
+// in smoke-real under the W5 real-host standard.
 import { existsSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
-import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver'
+import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
 import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
 
 const bundlePath = (dir: string): string =>
   fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
 
+const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
+const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
+
 /** id ↔ bundle table for the success pass (the complete Web UI assembly). */
-const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
+const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
   { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
+  { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
+  { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
   { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
   { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
 ]
 
-/** Manifest served by the fake registry: one live bundle row, one missing row. */
-const ROWS: WebPluginBootEntry[] = [
-  { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
-  { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
-]
-const LAYOUT_BUNDLE = bundlePath('ui-layout')
+const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
+
+const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
+  ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
+
+const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
+  ...(p.inject !== undefined ? { inject: p.inject } : {}),
+  ...(p.immediately === true ? { immediately: true } : {}),
+}))
+
+/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
+const FAIL_GRAPH: WebBootGraph = {
+  rev: 'e2e-fail',
+  entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
+}
+
+/** Graph for the success pass: the complete assembly. */
+const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
+
+/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
+function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
+  return {
+    graph: () => graph,
+    clientPath: (id: string) => byId.get(id),
+    onRebuilt: () => () => undefined,
+  }
+}
 
 describe('web boot chain (keyless, real carrier)', () => {
   let server: Awaited<ReturnType<typeof startWebServer>>
@@ -52,10 +78,7 @@ describe('web boot chain (keyless, real carrier)', () => {
       port,
       distIndex: DIST_INDEX,
       apiHandler,
-      webPlugins: {
-        snapshot: () => ROWS,
-        clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
-      },
+      webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
     }, (err) => { pageErrors.push(`server: ${String(err)}`) })
     browser = await chromium.launch()
     page = await browser.newPage()
@@ -68,16 +91,25 @@ describe('web boot chain (keyless, real carrier)', () => {
     await server?.close()
   })
 
-  it('GET / injects the manifest verbatim', async () => {
+  it('GET / injects the entry graph verbatim', async () => {
     onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
     const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
-    expect(boot).toEqual({ plugins: ROWS })
+    expect(boot).toEqual(FAIL_GRAPH)
   })
 
   it('serves a real bundle through the plugins endpoint', async () => {
-    const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`)
+    const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
     expect(res.status()).toBe(200)
-    expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
+    expect(await res.text()).toContain('window.__ModuleLoader__.load')
+  })
+
+  it('boots to the loading page and fail-louds the absent entry', async () => {
+    onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
+    await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
+    await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
+    await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
+    // The real UI must not have flipped in: the gate opens only on settled.
+    expect(await page.locator('[class*="frame"]').count()).toBe(0)
   })
 
   it('applies the token sheets before any plugin CSS', async () => {
@@ -87,7 +119,6 @@ describe('web boot chain (keyless, real carrier)', () => {
 })
 
 describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
-  const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
   let server: Awaited<ReturnType<typeof startWebServer>>
   let browser: Browser
   let page: Page
@@ -95,14 +126,9 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
 
   beforeAll(async () => {
     requireDist()
+    const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
     if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
     const port = await probeFreePort()
-    const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
-      const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
-      if (p.immediately === true) row.immediately = true
-      return row
-    })
-    const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
     // ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
     const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
     server = await startWebServer({
@@ -110,7 +136,7 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
       port,
       distIndex: DIST_INDEX,
       apiHandler,
-      webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) },
+      webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
     }, (err) => { pageErrors.push(`server: ${String(err)}`) })
     browser = await chromium.launch()
     page = await browser.newPage()
@@ -135,8 +161,8 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
   it('every plugin CSS landed with its ownership tag', async () => {
     const owners = await page.evaluate(() =>
       [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
-    expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
-    expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
+    expect(owners).toContain(LAYOUT_ID)
+    expect(owners).toContain(SIDEBAR_ID)
   })
 
   it('collapsed sidebar animates to a 56px rail with the four controls', async () => {

+ 17 - 6
apps/web/vite.config.ts

@@ -10,17 +10,28 @@ export default defineConfig({
     // Workspace packages resolve to SOURCE: package.json exports point at lib
     // for Node/type consumers, but the browser bundle must compile src directly
     // so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
-    // Only the shell's static surface is aliased — UI plugin packages are NOT
-    // bundled here; they arrive as dynamic bundles through the client loader.
-    // Order matters — subpath aliases must win over bare-name prefixes.
+    // Only the shell's normal-package surface is aliased — plugin packages are
+    // NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime
+    // bundles through the client module system. Order matters — subpath
+    // aliases must win over bare-name prefixes.
     alias: [
+      // Browserization of the vendored cordis Loader: its only node-only
+      // import; the two process probes are mapped by `define` below.
+      { find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
       { find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') },
-      { find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
-      { find: /^@deepseek-ai\/dsh-client-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') },
-      { find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
     ],
   },
+  define: {
+    // vendored loader internal.ts: fromInternal() probes the Node major —
+    // "0.0.0" takes neither branch, returning undefined (exactly the empty
+    // internal slot the shell boot fills with the client module loader).
+    'process.versions.node': '"0.0.0"',
+    'process.execArgv': '[]',
+    // vendored loader index.ts: envData falls to its default branch.
+    'process.env.CORDIS_SHARED': 'undefined',
+  },
 })

+ 20 - 0
packages/client/modules/README.md

@@ -0,0 +1,20 @@
+# @deepseek-ai/dsh-client-modules
+
+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` as its `internal` seam — 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)` → export surface, 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 name the same surface (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__`) → fetch + execute + 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 fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
+
+## Model Experience
+
+None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
+- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.

+ 37 - 0
packages/client/modules/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@deepseek-ai/dsh-client-modules",
+  "description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "BSD-3-Clause",
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 175 - 0
packages/client/modules/src/index.ts

@@ -0,0 +1,175 @@
+/**
+ * Client module system: the browser peer of Node's internal ESM loader, built
+ * as a lazy CJS table. The vendored cordis Loader consumes this object
+ * through its `internal` seam (the only call site is `EntryTree.import` →
+ * `internal.import`), which keeps entry governance (fiber lifecycle, inject
+ * waiting, update/refresh) entirely on the vendored side while this package
+ * owns code arrival.
+ *
+ * Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
+ * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
+ * side effect — including CSS injection — lives inside the factory closure
+ * and runs at materialization, not at script execution. Materialization
+ * (factory(require) → export surface) happens on first import/require and is
+ * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
+ * another registered-but-unmaterialized module materializes it recursively,
+ * so load order needs no external sequencing.
+ *
+ * Resolution branch order (import): seed word → shell instance; memoized
+ * record → surface; static registry (shell-own modules, e.g. app-shell) →
+ * module; registered factory → materialize; graph row → fetch + execute +
+ * materialize; anything else → throw (loud — the runtime mirror of the
+ * build-time bundle purity gate). The synchronous `require` handed to
+ * factories walks the same order minus the fetch branch: fetching is async,
+ * so only already-executed bundles can be required — and cross-plugin value
+ * imports are a build error anyway.
+ * @module @deepseek-ai/dsh-client-modules
+ */
+
+import { ClientModuleLoaderImpl } from './loader.ts'
+
+export { ClientModuleLoaderImpl }
+
+declare module 'cordis' {
+  interface Context {
+    /** The client module system the web shell provides at boot (contract C5). */
+    modules: ClientModuleLoader
+  }
+}
+
+/**
+ * One composed client entry pushed by the host (web2 §0 graph row).
+ * `immediately` marks stage-one prefetch; `inject` is informational graph
+ * metadata (the authoritative edges live in each package's dshClient
+ * declaration and reach fibers through entry creation).
+ *
+ * Wire contract, held on both sides: the producing peer lives in
+ * `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
+ * dependencies, so neither side imports the other's shape — drift between
+ * the two declarations is a bug against the web2 contract).
+ */
+export interface WebBootEntry {
+  /** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
+  id: string
+  /**
+   * Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
+   * shell-owned pseudo rows (app-shell) whose module is statically registered
+   * — a row that is neither fetchable nor static-registered fails loud.
+   */
+  url?: string
+  /** Bundle content hash (cache-busting consistency anchor); absent with url. */
+  rev?: string
+  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+  inject?: string[]
+  /** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
+  immediately?: boolean
+}
+
+/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
+export 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). */
+  entries: WebBootEntry[]
+}
+
+/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
+export interface ClientPluginHandoff {
+  /** Plugin id (package name) — the registration key; must match the graph row being executed. */
+  id: string
+  /**
+   * Closure factory holding the whole bundle body: receives the synchronous
+   * require bound to the module table and returns the bundle's export
+   * surface. Runs once, at materialization.
+   */
+  factory: (require: (spec: string) => unknown) => Record<string, unknown>
+}
+
+/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
+export interface DshWindow {
+  /** Host-composed entry graph, injected before the shell bundle runs. */
+  __DSH_BOOT__?: WebBootGraph
+  /** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
+  __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
+}
+
+/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
+export interface ClientModuleRecord {
+  /** Module id (entry name / package name). */
+  id: string
+  /** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
+  surface: unknown
+  /** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
+  styles: string[]
+  /** Observed `require()` edges (module-graph seam; only table words can appear today). */
+  edges: Set<string>
+}
+
+/**
+ * The internal-seam subset the vendored Loader and the client HMR plugin
+ * consume. Mounted on `ctx.loader.internal` by the shell boot and provided
+ * as `ctx.modules` (contract C5).
+ */
+export interface ClientModuleLoader {
+  /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
+  version: 'client'
+  /** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
+  loadCache: Map<string, ClientModuleRecord>
+  /**
+   * Internal seam consumed by the vendored Loader's `tree.import`. Resolves
+   * `specifier` through the branch order documented on the module, fetching
+   * and executing a bundle when needed.
+   * @param specifier - module specifier (entry name or table word).
+   * @param parentURL - importer URL (unused — the client module graph is flat).
+   * @param attrs - import attributes (unused; interface parity with Node's seam).
+   * @returns the module's export surface.
+   */
+  import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
+  /**
+   * Register a shell-own module (app-shell — code that ships inside the shell
+   * bundle and never arrives as a plugin bundle).
+   * @param id - entry name (shell-owned pseudo id).
+   * @param module - the statically imported module namespace.
+   */
+  registerStatic(id: string, module: unknown): void
+  /**
+   * Stage-one arrival: fetch the entry's bundle and execute it, registering
+   * its factory (no materialization — module side effects wait for import).
+   * No-op for static-registered ids and ids whose factory is already
+   * registered; concurrent calls share one in-flight task. To force a fresh
+   * fetch (HMR), {@link invalidate} first.
+   * @param id - graph entry name.
+   */
+  prefetch(id: string): Promise<void>
+  /**
+   * Full reset of one module: drop its registered factory, its materialized
+   * record, and any consumed bundle text, so the next prefetch/import
+   * refetches and re-executes (the HMR invalidation hook).
+   * @param id - entry name to invalidate.
+   */
+  invalidate(id: string): void
+}
+
+/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
+export interface ClientModuleLoaderOptions {
+  /** Host-composed entry graph. */
+  graph: WebBootGraph
+  /** Module-table seed: platform-singleton specifier → shell instance. */
+  staticModules: Record<string, unknown>
+  /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
+  fetchBundle?: (url: string) => Promise<string>
+  /**
+   * Bundle execution seam (synchronously performs the load() registration).
+   * Defaults to a <script> element carrying the code.
+   */
+  executeBundle?: (code: string, url: string) => void
+}
+
+/**
+ * Build the client module system.
+ * @param options - entry graph, module-table staticModules, fetch/execute seams.
+ * @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
+ */
+export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
+  return new ClientModuleLoaderImpl(options)
+}

+ 34 - 0
packages/client/modules/src/invariant.ts

@@ -0,0 +1,34 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
+ * @module @deepseek-ai/dsh-client-modules/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
+
+/** Cordis companion plugin name. */
+export const name = 'client-modules-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the module loader is pre-plugin kernel machinery —
+ * it emits no cordis events (the vendored Loader owns entry lifecycle events)
+ * and its mutable state (loadCache, handoff slot) lives below the plugin
+ * layer where invariant observers cannot mount before it runs; resolve branch
+ * order and handoff discipline are asserted by the web boot specs against the
+ * real execution path.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 223 - 0
packages/client/modules/src/loader.ts

@@ -0,0 +1,223 @@
+/**
+ * ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
+ * seam. The conceptual contract (lazy CJS model, resolution branch order) is
+ * documented on the package module and the public interfaces in `./index.ts`;
+ * this file owns the state tables and the fetch/execute/materialize machinery.
+ */
+import type {
+  ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
+  ClientPluginHandoff, DshWindow, WebBootEntry,
+} from './index.ts'
+
+/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
+interface RegisteredFactory {
+  factory: ClientPluginHandoff['factory']
+  url: string
+}
+
+/** Default bundle fetch seam: same-origin fetch().text(). */
+const defaultFetchBundle = async (url: string): Promise<string> => {
+  const res = await fetch(url)
+  if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
+  return res.text()
+}
+
+/** Default bundle execution seam: a <script> element carrying the code. */
+const defaultExecuteBundle = (code: string, url: string): void => {
+  const el = document.createElement('script')
+  // Inline execution (not src) so the fetch half stays parallelizable; the
+  // sourceURL comment keeps devtools stack frames attributed to the bundle.
+  el.textContent = `${code}\n//# sourceURL=${url}`
+  document.head.appendChild(el)
+}
+
+const urlOf = (row: WebBootEntry): string => {
+  // url is conditional on the wire (shell-own pseudo rows omit it); those
+  // ids resolve through the static registry and never reach a fetch.
+  if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
+  return row.url
+}
+
+/**
+ * A plugin bundle IS its package's client half: `<id>/client` (the exports
+ * subpath external bundles emit) and the bare graph id name the same
+ * surface, so table lookups normalize the suffix away.
+ */
+const stripClientSuffix = (spec: string): string =>
+  spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
+
+/**
+ * Claim and inventory the <style> tags a factory injected during
+ * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
+ * any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
+ */
+const claimStyles = (id: string): string[] => {
+  if (typeof document === 'undefined') return []
+  for (const el of document.querySelectorAll('style:not([data-plugin])')) {
+    el.setAttribute('data-plugin', id)
+  }
+  const owned: string[] = []
+  for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
+    owned.push(el.getAttribute('data-plugin-css') ?? id)
+  }
+  return owned
+}
+
+/**
+ * The client module system: state tables plus the arrival/materialization
+ * machinery implementing {@link ClientModuleLoader} (whose members carry the
+ * seam contract docs). Construction indexes the boot graph and installs the
+ * `window.__ModuleLoader__` registration sink (contract C6) — once per page.
+ */
+export class ClientModuleLoaderImpl implements ClientModuleLoader {
+  readonly version = 'client'
+  readonly loadCache = new Map<string, ClientModuleRecord>()
+
+  private readonly seed: Map<string, unknown>
+  private readonly statics = new Map<string, unknown>()
+  private readonly factories = new Map<string, RegisteredFactory>()
+  /** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
+  private readonly pendingArrival = new Map<string, Promise<void>>()
+  /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
+  private readonly materializing = new Set<string>()
+  private readonly graphRows = new Map<string, WebBootEntry>()
+  // Execution URL of the bundle currently being executed (bound into the
+  // factory registration so diagnostics can name the source).
+  private executingUrl = ''
+
+  private readonly fetchBundle: (url: string) => Promise<string>
+  private readonly executeBundle: (code: string, url: string) => void
+
+  /**
+   * Build the module system over the host graph.
+   * @param options - entry graph, module-table staticModules, fetch/execute seams.
+   */
+  constructor(options: ClientModuleLoaderOptions) {
+    this.seed = new Map(Object.entries(options.staticModules))
+    this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
+    this.executeBundle = options.executeBundle ?? defaultExecuteBundle
+
+    for (const entry of options.graph.entries) {
+      if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
+      this.graphRows.set(entry.id, entry)
+    }
+
+    const win = globalThis as DshWindow
+    if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
+    win.__ModuleLoader__ = {
+      load: (handoff: ClientPluginHandoff): void => {
+        // Registration is keyed by the handoff id; a duplicate means a bundle
+        // executed twice without an invalidate — always a bug, always loud.
+        if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
+        this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
+      },
+    }
+  }
+
+  /** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
+  private arrive(row: WebBootEntry): Promise<void> {
+    const { id } = row
+    const pending = this.pendingArrival.get(id)
+    if (pending !== undefined) return pending
+    if (this.factories.has(id)) return Promise.resolve()
+    const task = (async (): Promise<void> => {
+      const url = urlOf(row)
+      const code = await this.fetchBundle(url)
+      this.executingUrl = url
+      try {
+        this.executeBundle(code, url)
+      } finally {
+        this.executingUrl = ''
+      }
+      if (!this.factories.has(id)) {
+        throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
+      }
+    })().finally(() => { this.pendingArrival.delete(id) })
+    this.pendingArrival.set(id, task)
+    return task
+  }
+
+  /** Materialize a registered factory (synchronous; memoized in loadCache). */
+  private materialize(id: string): ClientModuleRecord {
+    const existing = this.loadCache.get(id)
+    if (existing !== undefined) return existing
+    const registered = this.factories.get(id)
+    /* v8 ignore next -- callers check the factory branch before dispatching here. */
+    if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
+    if (this.materializing.has(id)) {
+      throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
+    }
+    this.materializing.add(id)
+    try {
+      const edges = new Set<string>()
+      const surface = registered.factory(this.makeRequire(edges))
+      const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
+      this.loadCache.set(id, record)
+      return record
+    } finally {
+      this.materializing.delete(id)
+    }
+  }
+
+  /**
+   * The synchronous require answered to factories: seed → static → memoized
+   * record → registered factory (recursive materialization — this is what
+   * makes load order self-resolving). Fetching is async and therefore
+   * unreachable from here; an unregistered plugin specifier is loud (and a
+   * cross-plugin value import is already a build error upstream).
+   */
+  private makeRequire(edges: Set<string>): (spec: string) => unknown {
+    return (spec: string): unknown => {
+      edges.add(spec)
+      if (this.seed.has(spec)) return this.seed.get(spec)
+      if (this.statics.has(spec)) return this.statics.get(spec)
+      const id = stripClientSuffix(spec)
+      const record = this.loadCache.get(id)
+      if (record !== undefined) return record.surface
+      if (this.factories.has(id)) return this.materialize(id).surface
+      throw new Error(
+        `client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
+        + 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
+      )
+    }
+  }
+
+  async import(specifier: string): Promise<unknown> {
+    if (this.seed.has(specifier)) return this.seed.get(specifier)
+    const existing = this.loadCache.get(specifier)
+    if (existing !== undefined) return existing.surface
+    if (this.statics.has(specifier)) {
+      const surface = this.statics.get(specifier)
+      this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
+      return surface
+    }
+    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 shell-own module, `
+          + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
+        )
+      }
+      await this.arrive(row)
+    }
+    return this.materialize(specifier).surface
+  }
+
+  registerStatic(id: string, module: unknown): void {
+    if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
+    this.statics.set(id, module)
+  }
+
+  async prefetch(id: string): Promise<void> {
+    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)
+  }
+
+  invalidate(id: string): void {
+    this.factories.delete(id)
+    this.loadCache.delete(id)
+  }
+}

+ 24 - 0
packages/client/modules/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": []
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 2 - 6
packages/client/runtime/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-runtime",
-  "description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
+  "description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -15,10 +15,6 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
-    "./loader": {
-      "types": "./lib/types/client/loader/index.d.ts",
-      "default": "./lib/loader.js"
-    },
     "./client": {
       "types": "./lib/types/client/index.d.ts",
       "default": "./lib/client.js"
@@ -37,6 +33,7 @@
   "dependencies": {
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "immer": "^10.1.1",
@@ -56,7 +53,6 @@
     "lib/index.js",
     "lib/invariant.js",
     "lib/client.js",
-    "lib/loader.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"

+ 5 - 46
packages/client/runtime/src/client/index.ts

@@ -2,17 +2,15 @@
  * Browser half: the whole runtime contract surface (api-contracts v3 §4) —
  * SlotsService (declaration ledger + renderer seam + store axis, built-in
  * 'root'), SessionsService (list store + current selection + scope tree +
- * object layer), the ClientLoader interface, and the cordis Context/Events
- * merges. apply
- * mounts ctx.slots + ctx.sessions and wires the connection stream loop into
- * the object layer. The loader machinery implementation is NOT in the plugin
- * bundle — it ships via the package's `./loader` subpath, statically held by
- * the web shell (a loader cannot load itself).
+ * object layer), and the cordis Context/Events merges. apply mounts
+ * ctx.slots + ctx.sessions and wires the connection stream loop into the
+ * object layer. A static-arrival entry: the web shell bundles this module
+ * and mounts it through the host graph (module loading lives in
+ * @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
  */
 import type { Context } from 'cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SnapshotStore } from './contract/store.ts'
 import { SlotsService } from './slots.ts'
 import { SessionsService } from './sessions/service.ts'
 import type { SessionListState } from './sessions/service.ts'
@@ -95,48 +93,9 @@ declare module 'cordis' {
   interface Context {
     slots: import('./slots.ts').SlotsService
     sessions: import('./sessions/service.ts').SessionsService
-    loader: ClientLoader
   }
 }
 
-/** One __DSH_BOOT__ manifest row. */
-export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
-
-/** Per-plugin load status store shape. */
-export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
-
-/**
- * Client bundle loader. The immediately group loads first (parallel fetch,
- * apply in inject topology order); remaining plugins follow in inject
- * topology. Loaded bundle export surfaces are registered back into the
- * require module table. Implementation lives in the `./loader` subpath
- * (shell-held machinery).
- */
-export interface ClientLoader {
-  /** Start loading from window.__DSH_BOOT__ (non-blocking). */
-  start(): void
-  /**
-   * Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
-   * @param id - plugin id (package name).
-   */
-  load(id: string): Promise<void>
-  /**
-   * Unload a plugin. P-I: not implemented (full chain lands with HMR).
-   * @param id - plugin id.
-   */
-  unload(id: string): Promise<void>
-  /** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
-  settled(): Promise<void>
-  /**
-   * Read a loaded module's export surface from the module table (same
-   * implementation the bundle-facing require uses; unknown spec throws).
-   * @param spec - module specifier (package name or seeded library id).
-   */
-  requireModule(spec: string): unknown
-  /** Per-plugin status store. */
-  readonly status: SnapshotStore<LoaderStatus>
-}
-
 /** Required services: the wire handle mounted by the connection plugin. */
 export const inject = ['connection']
 

+ 0 - 247
packages/client/runtime/src/client/loader/index.ts

@@ -1,247 +0,0 @@
-/**
- * ClientLoader implementation (shell-held machinery — the loader cannot load
- * itself, so the web shell imports this subpath statically and mounts the
- * instance as ctx.loader; the runtime package's own client bundle never
- * includes it).
- *
- * Load chain per plugin: fetch bundle text → execute (script injection) → the
- * bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
- * handoff, id reconciled) → factory(require) with require bound to the module
- * table → ctx.plugin(exports.apply) → the export surface is registered into
- * the module table under the plugin id (inject topology guarantees later
- * loaders can require earlier ones) → <style data-plugin> ownership recorded.
- *
- * start(): the `immediately` group is fetched in parallel and executed in
- * group-internal inject topology (execution is serial — the handoff slot is
- * single); a full-group barrier precedes the remaining plugins, which then
- * load one by one in inject topology.
- */
-import type { Context } from 'cordis'
-import { createSnapshotStore } from '../contract/store.ts'
-import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
-
-export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
-
-/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
-export interface ClientPluginHandoff {
-  /** Plugin id (package name) — must match the manifest row being loaded. */
-  id: string
-  /**
-   * Closure factory: receives the DI require and returns the module's export
-   * surface; an `apply` export is applied as a cordis plugin.
-   */
-  factory: (require: (spec: string) => unknown) => Record<string, unknown>
-}
-
-/** Window surface the loader owns (bundle side of the handoff protocol). */
-interface DshWindow {
-  __DSH_BOOT__?: { plugins: BootPluginEntry[] }
-  DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
-}
-
-/** Options for createClientLoader (assembled by the web shell at boot). */
-export interface ClientLoaderOptions {
-  /** Client root context: plugin applies mount under it. */
-  ctx: Context
-  /**
-   * Seeded module table: pure-library entities (react, react-dom, cordis,
-   * ui-slots, web-react, ui-primitives). The loader takes ownership and
-   * registers loaded bundle export surfaces alongside them.
-   */
-  modules: Record<string, unknown>
-  /**
-   * Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
-   * same protocol shape.
-   */
-  boot?: { plugins: BootPluginEntry[] }
-  /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
-  fetchBundle?: (url: string) => Promise<string>
-  /**
-   * Bundle execution seam (serial half; execution synchronously performs the
-   * loadPlugin handoff). Defaults to a <script> element carrying the code.
-   */
-  executeBundle?: (code: string, url: string) => void
-}
-
-/** Per-plugin bookkeeping across the load chain. */
-interface PluginRecord {
-  entry: BootPluginEntry
-  state: 'idle' | 'loading' | 'active' | 'failed'
-  fetch?: Promise<string>
-  load?: Promise<void>
-}
-
-const NOT_LOADED = Symbol('dsh.loader.not-loaded')
-
-/**
- * Build the client bundle loader.
- * @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
- * @returns the ClientLoader the shell mounts as ctx.loader.
- */
-export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
-  const { ctx } = options
-  const win = globalThis as DshWindow
-  const boot = options.boot ?? win.__DSH_BOOT__
-  if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
-
-  const modules = new Map<string, unknown>(Object.entries(options.modules))
-  const records = new Map<string, PluginRecord>()
-  for (const entry of boot.plugins) {
-    if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
-    records.set(entry.id, { entry, state: 'idle' })
-  }
-
-  const status = createSnapshotStore<LoaderStatus>({})
-  const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
-    status.update((draft) => { draft[id] = state })
-  }
-
-  // Single-slot handoff: bundle execution synchronously calls loadPlugin;
-  // doLoad arms the slot before executing and reconciles the id after.
-  let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
-  if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
-  win.DSHClientProxy = {
-    loadPlugin: (handoff: ClientPluginHandoff): void => {
-      if (slot !== NOT_LOADED) {
-        throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
-      }
-      slot = handoff
-    },
-  }
-
-  const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
-    const res = await fetch(url)
-    if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
-    return res.text()
-  })
-
-  const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
-    const el = document.createElement('script')
-    // Inline execution (not src) so the fetch half stays parallelizable; the
-    // sourceURL comment keeps devtools stack frames attributed to the bundle.
-    el.textContent = `${code}\n//# sourceURL=${url}`
-    document.head.appendChild(el)
-  })
-
-  const requireModule = (spec: string): unknown => {
-    if (!modules.has(spec)) {
-      throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
-    }
-    return modules.get(spec)
-  }
-
-  /** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
-  const claimStyles = (id: string): void => {
-    if (typeof document === 'undefined') return
-    for (const el of document.querySelectorAll('style:not([data-plugin])')) {
-      el.setAttribute('data-plugin', id)
-    }
-  }
-
-  /** Start (or reuse) the parallelizable fetch half. */
-  const prefetch = (record: PluginRecord): Promise<string> =>
-    (record.fetch ??= fetchBundle(record.entry.url))
-
-  async function doLoad(record: PluginRecord): Promise<void> {
-    const { id } = record.entry
-    record.state = 'loading'
-    publish(id, 'loading')
-    try {
-      // Dependencies must already be active (start() sequences this; direct
-      // load() callers get the same fail-loud check).
-      for (const dep of record.entry.inject) {
-        const depRecord = records.get(dep)
-        if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
-        if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
-      }
-      const code = await prefetch(record)
-      executeBundle(code, record.entry.url)
-      if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
-      const handoff = slot
-      slot = NOT_LOADED
-      if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
-      const exports = handoff.factory(requireModule)
-      if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
-      // The whole export surface is the plugin: cordis object-plugin form
-      // keeps the bundle's exported `inject`/`name` (an apply-only pass would
-      // silently drop the dependency declaration — postmortem 0001).
-      const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
-      await fiber.await()
-      // Register under both specifier forms bundles emit: the bare package
-      // name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
-      // form) — the loaded surface IS the client half either way.
-      modules.set(id, exports)
-      modules.set(`${id}/client`, exports)
-      claimStyles(id)
-      record.state = 'active'
-      publish(id, 'active')
-    } catch (error) {
-      record.state = 'failed'
-      publish(id, 'failed')
-      throw error
-    }
-  }
-
-  const load = (id: string): Promise<void> => {
-    const record = records.get(id)
-    if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
-    record.load ??= doLoad(record)
-    return record.load
-  }
-
-  /** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
-  const topo = (ids: string[]): string[] => {
-    const pool = new Set(ids)
-    const ordered: string[] = []
-    const done = new Set<string>()
-    const visiting = new Set<string>()
-    const visit = (id: string): void => {
-      if (done.has(id)) return
-      if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
-      visiting.add(id)
-      const record = records.get(id)
-      /* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
-      if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
-      for (const dep of record.entry.inject) {
-        if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
-        if (pool.has(dep)) visit(dep)
-      }
-      visiting.delete(id)
-      done.add(id)
-      ordered.push(id)
-    }
-    for (const id of ids) visit(id)
-    return ordered
-  }
-
-  let settledPromise: Promise<void> | undefined
-
-  async function run(): Promise<void> {
-    const all = [...records.values()]
-    const early = all.filter(r => r.entry.immediately === true)
-    const rest = all.filter(r => r.entry.immediately !== true)
-    // Early group: parallel fetch (all requests in flight at once), serial
-    // inject-topology execution, full-group barrier before anything else.
-    const earlyOrder = topo(early.map(r => r.entry.id))
-    for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
-    for (const id of earlyOrder) await load(id)
-    // Remaining plugins: one by one in inject topology.
-    for (const id of topo(rest.map(r => r.entry.id))) await load(id)
-  }
-
-  return {
-    start: () => {
-      settledPromise ??= run()
-      // Failures surface through settled()/status — start() itself is fire-and-forget.
-      settledPromise.catch(() => {})
-    },
-    load,
-    unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
-    settled: () => {
-      if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
-      return settledPromise
-    },
-    requireModule,
-    status,
-  }
-}

+ 0 - 289
packages/client/runtime/tests/client-loader.spec.ts

@@ -1,289 +0,0 @@
-/**
- * ClientLoader: handoff protocol (single slot, id reconciliation), DI require
- * with export-surface re-registration, immediately-group barrier (parallel
- * fetch / topology execution / full-group barrier), status store, settled,
- * failure modes (missing handoff, unknown dep, cycle, unload stub).
- */
-import { Context } from 'cordis'
-import { afterEach, describe, expect, it } from 'vitest'
-import { createClientLoader } from '../src/client/loader/index.ts'
-import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
-
-type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
-const win = globalThis as Win
-
-afterEach(() => {
-  delete win.DSHClientProxy
-  delete win.__DSH_BOOT__
-})
-
-interface FakeBundle {
-  handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
-}
-
-interface Bench {
-  loader: ReturnType<typeof createClientLoader>
-  fetched: string[]
-  executed: string[]
-  fetchGate: Map<string, () => void>
-}
-
-/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
-function bench(
-  plugins: BootPluginEntry[],
-  bundles: Record<string, FakeBundle>,
-  opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
-): Bench {
-  const ctx = new Context()
-  const fetched: string[] = []
-  const executed: string[] = []
-  const fetchGate = new Map<string, () => void>()
-  const loader = createClientLoader({
-    ctx,
-    modules: opts.modules ?? { react: { marker: 'react' } },
-    boot: { plugins },
-    fetchBundle: (url) => {
-      fetched.push(url)
-      if (opts.gated?.includes(url) === true) {
-        return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
-      }
-      return Promise.resolve(url)
-    },
-    executeBundle: (code) => {
-      executed.push(code)
-      const bundle = bundles[code]
-      if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
-      if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
-      if (typeof bundle.handoff === 'function') {
-        win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
-        return
-      }
-      win.DSHClientProxy?.loadPlugin(bundle.handoff)
-    },
-  })
-  return { loader, fetched, executed, fetchGate }
-}
-
-const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
-  ({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
-
-const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
-  handoff: require => ({
-    apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
-    require,
-    ...exports,
-  }),
-})
-
-describe('load chain', () => {
-  it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
-    const applied: string[] = []
-    const b = bench(
-      [entry('fake-base', [], true), entry('feature', ['fake-base'])],
-      {
-        '/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
-        '/plugins/feature/client.js': {
-          handoff: (require) => {
-            // Later loader requires the earlier one's export surface (inject topology guarantee).
-            const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
-            const base = require(fakeBase) as { helper: string }
-            expect(base.helper).toBe('base-helper')
-            expect((require('react') as { marker: string }).marker).toBe('react')
-            return { apply: () => { applied.push('feature') } }
-          },
-        },
-      },
-    )
-    b.loader.start()
-    await b.loader.settled()
-    expect(applied).toEqual(['fake-base', 'feature'])
-    expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
-    expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
-    expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
-  })
-
-  it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
-    const b = bench(
-      [entry('a', [], true), entry('b', ['a'], true), entry('later')],
-      {
-        '/plugins/a/client.js': okBundle(),
-        '/plugins/b/client.js': okBundle(),
-        '/plugins/later/client.js': okBundle(),
-      },
-      { gated: ['/plugins/a/client.js'] },
-    )
-    b.loader.start()
-    await Promise.resolve()
-    // Both early fetches are in flight before any execution; the late plugin is not fetched yet.
-    expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
-    expect(b.executed).toEqual([])
-    b.fetchGate.get('/plugins/a/client.js')?.()
-    await b.loader.settled()
-    expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
-  })
-
-  it('orders execution by inject topology within each group', async () => {
-    const b = bench(
-      [entry('z-ui', ['a-base']), entry('a-base')],
-      { '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
-    )
-    b.loader.start()
-    await b.loader.settled()
-    expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
-  })
-})
-
-describe('failure modes (fail loud)', () => {
-  it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
-    const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
-    b.loader.start()
-    await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
-    expect(b.loader.status.getSnapshot().silent).toBe('failed')
-  })
-
-  it('rejects on manifest/handoff id mismatch', async () => {
-    const b = bench([entry('expected')], {
-      '/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
-    })
-    b.loader.start()
-    await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
-  })
-
-  it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
-    // Sequential benches: each loader owns the window proxy, so release it between them.
-    const fresh = <T>(build: () => T): T => {
-      delete win.DSHClientProxy
-      return build()
-    }
-
-    const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
-    missing.loader.start()
-    await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
-
-    const cyclic = fresh(() => bench(
-      [entry('p', ['q']), entry('q', ['p'])],
-      { '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
-    ))
-    cyclic.loader.start()
-    await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
-
-    const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
-    applyless.loader.start()
-    await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
-
-    const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
-    await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
-
-    expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
-  })
-
-  it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
-    expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
-    const b = bench([], {})
-    expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
-    // First bench installed the proxy; a second loader must refuse.
-    expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
-  })
-
-  it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
-    const b = bench(
-      [entry('dep', [], true), entry('needy', ['dep'])],
-      { '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
-    )
-    await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
-  })
-
-  it('direct load() naming an unknown inject target fails loud', async () => {
-    const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
-    await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
-  })
-
-  it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
-    // The fire-and-forget prefetch swallow arm must absorb the early
-    // rejection; the awaited load surfaces the same failure via settled().
-    const ctx = new Context()
-    delete win.DSHClientProxy
-    const loader = createClientLoader({
-      ctx,
-      modules: {},
-      boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
-      fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
-      executeBundle: () => {},
-    })
-    loader.start()
-    await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
-  })
-
-  it('unload is the P-I stub', async () => {
-    const b = bench([], {})
-    await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
-  })
-})
-
-describe('DOM default seams (stubbed globals)', () => {
-  it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
-    const origFetch = globalThis.fetch
-    const appended: { textContent?: string | null }[] = []
-    const styleTag = {
-      attrs: {} as Record<string, string>,
-      setAttribute(k: string, v: string) { this.attrs[k] = v },
-    }
-    const fakeDoc = {
-      createElement: () => {
-        const el = { textContent: null as string | null }
-        return el
-      },
-      head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
-      querySelectorAll: () => [styleTag],
-    }
-    const g = globalThis as { document?: unknown; fetch: typeof fetch }
-    g.document = fakeDoc
-    g.fetch = (url: URL | RequestInfo) => Promise.resolve(
-      (typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
-        ? new Response('x', { status: 500 })
-        : new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
-    )
-    try {
-      delete win.DSHClientProxy
-      const ctx = new Context()
-      const loader = createClientLoader({
-        ctx,
-        modules: {},
-        boot: { plugins: [
-          { id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
-          { id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
-        ] },
-        // NO seams injected (keys omitted, not undefined — exactOptional):
-        // the DOM defaults are under test.
-      })
-      const seamHandoff: ClientPluginHandoff = {
-        id: 'seam-ok',
-        factory: () => ({ apply: () => {} }),
-      }
-      // Default executeBundle only APPENDS the script element (no execution in
-      // our fake DOM), so drive the handoff manually before load resolves it.
-      const loadOk = loader.load('seam-ok')
-      await Promise.resolve()
-      ;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
-      await loadOk
-      expect(appended).toHaveLength(1)
-      expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
-      expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
-      await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
-    } finally {
-      g.fetch = origFetch
-      delete (globalThis as { document?: unknown }).document
-    }
-  })
-})
-
-describe('handoff slot protocol', () => {
-  it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
-    delete win.DSHClientProxy
-    createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
-    const proxy = (globalThis as Win).DSHClientProxy
-    proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
-    expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
-      .toThrow(/overlapping loadPlugin handoff/)
-  })
-})

+ 3 - 0
packages/client/runtime/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../connection"
     },
+    {
+      "path": "../../host/apiproxy"
+    },
     {
       "path": "../../llm/llm"
     },

+ 1 - 21
packages/client/runtime/tsdown.config.ts

@@ -1,23 +1,3 @@
-import type { UserConfig } from 'tsdown'
 import { clientBundle } from '../tsdown.client.ts'
 
-/**
- * Standard dual-entry shape plus the loader lib half: exports["./loader"]
- * promises lib/loader.js (the web shell statically imports the machinery —
- * a loader cannot load itself), and the shared preset only emits
- * lib/{index,invariant}.js, so the extra config supplies it.
- */
-const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
-
-const loaderLib: UserConfig = {
-  entry: { loader: 'lib/types/client/loader/index.js' },
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'neutral',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-}
-
-export default [...configs, loaderLib]
+export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 7 - 4
packages/client/web/README.md

@@ -1,8 +1,12 @@
 # @deepseek-ai/dsh-client-web
 
-Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader machinery (statically held; a loader cannot load itself), pure-library module-table seeding, AppRoot (boot loading page → settled → full UI in one switch), and the SessionProvider/scopedSlots assembly closure. The vite application entry lives in apps/web and only calls `bootWebShell`. Contract: api-contracts v3 §9.3.
+Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
 
-The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
+Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
+
+`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
+
+The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
 
 The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
 
@@ -16,6 +20,5 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **One-shot rendering by design** — the UI waits for `loader.settled()`; a single plugin failure keeps the loading page with a loud error, no partial availability (progressive rendering returns with its own project).
-- **No HMR** — the dev loop is tsdown watch + manual refresh for plugins; vite serves only the shell.
+- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
 - **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item.

+ 5 - 3
packages/client/web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-web",
-  "description": "Web shell library: bootWebShell (loader holding + module-table seeding + AppRoot gate + plugin assembly), consumed by the apps/web vite entry",
+  "description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -20,8 +20,7 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-client-connection": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
@@ -30,6 +29,8 @@
     "react-dom": "^18.2.0"
   },
   "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
@@ -37,6 +38,7 @@
     "typescript": "^6.0.3"
   },
   "peerDependencies": {
+    "@cordisjs/plugin-loader": "^1.0.0-rc.5",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },

+ 22 - 14
packages/client/web/src/AppRoot.tsx

@@ -1,39 +1,46 @@
 /**
- * Shell root: boot loading page → (loader settled) → real UI in one switch.
- * Pure shell component with zero plugin dependencies — before settled it may
- * only rely on itself; the real UI is produced by the boot assembly closure
- * (renderApp) once every plugin is active. A failed plugin keeps the loading
- * page and lists the failures (fail loud, no partial UI).
+ * Shell root: boot loading page → (boot settled) → real UI in one switch.
+ * Pure kernel component with zero plugin dependencies — before settled it may
+ * only rely on itself (the fail-loud presentation must not depend on the
+ * system whose failure it reports; the status/signal stores are kernel-own,
+ * web2 shell self-sufficiency rule); the real UI is produced by the
+ * app-shell entry once every entry is active. A failed boot keeps the
+ * loading page, lists the per-entry fiber states and the sweep report (fail
+ * loud, no partial UI).
  */
 import { useSyncExternalStore } from 'react'
 import type { ReactNode } from 'react'
-import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
+import type { KernelSignal, LoaderStatus } from './loader-status.ts'
 import css from './AppRoot.module.css'
 
-/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
+/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
 export interface AppRootProps {
-  /** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
-  settled: ObservableSnapshot<boolean>
-  /** Loader per-plugin status store (drives loading/failed rendering). */
-  status: SnapshotStore<LoaderStatus>
+  /** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
+  settled: KernelSignal<boolean>
+  /** Per-entry fiber-state projection store (drives loading/failed rendering). */
+  status: KernelSignal<LoaderStatus>
+  /** Boot failure report (the settle rejection message); undefined while loading or after success. */
+  error: KernelSignal<string | undefined>
   /** Builds the real UI; called only after settled. */
   renderApp: () => ReactNode
 }
 
-/** Boot gate: loading page until the loader settles; failures stay here. */
+/** Boot gate: loading page until the boot settles; failures stay here. */
 export function AppRoot(props: AppRootProps) {
   const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
   const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
+  const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
   const failed = Object.entries(status).filter(([, s]) => s === 'failed')
 
   if (settled) return <>{props.renderApp()}</>
 
+  const loud = error !== undefined || failed.length > 0
+
   return (
     <div className={css.boot}>
       <div className={css.card}>
         <div className={css.wordmark}>HARNESS</div>
-        {failed.length === 0
+        {!loud
           ? (
               <>
                 <div className={css.spinner} />
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
               <div className={css.failed}>
                 <div className={css.failedTitle}>Failed to load plugins</div>
                 {failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
+                {error !== undefined && <div className={css.failedItem}>{error}</div>}
               </div>
             )}
       </div>

+ 59 - 0
packages/client/web/src/app-shell.ts

@@ -0,0 +1,59 @@
+/**
+ * App-shell assembly plugin (design §3.4): the shell's ONLY composition
+ * responsibility, packaged as a normal static-arrival entry so the host graph
+ * stays the single composition authority. It rides the same entry lifecycle
+ * as every other plugin — the fiber waits on slots/sessions/layout, so by the
+ * time apply runs the layout entry is mounted and its export surface is
+ * readable from the governance side (module loadCache, design §2.6).
+ *
+ * The pseudo package id exists only in the host graph and the shell's static
+ * registry; there is no npm package behind it.
+ */
+import type { ReactNode } from 'react'
+import type { Context } from 'cordis'
+import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
+import { buildRenderApp } from './app.tsx'
+
+/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
+export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
+
+/** The assembled-UI face AppRoot renders once the boot settles. */
+export interface AppShellService {
+  /** Build (once) and render the real UI tree. */
+  renderApp: () => ReactNode
+}
+
+declare module 'cordis' {
+  interface Context {
+    /** The shell assembly face, provided by the app-shell entry once its inject set is active. */
+    appShell: AppShellService
+  }
+}
+
+/** Cordis plugin name. */
+export const name = 'app-shell'
+
+/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
+export const inject = ['slots', 'sessions', 'layout']
+
+/**
+ * Plugin body: install the React renderer into the slot system and provide
+ * the renderApp face (one ctx-level renderSlot('root') call).
+ * @param ctx - plugin context (inject set active).
+ */
+export function apply(ctx: Context): void {
+  // The renderer install is shell territory (web-react is shell-bundled),
+  // but ctx.slots exists only once the runtime entry is active — so it lands
+  // here, on the entry whose inject set guarantees that ordering.
+  ctx.slots.install(createSlotRenderer())
+
+  // Assemble once on first render: the closure must be identity-stable
+  // across AppRoot re-renders.
+  let renderApp: (() => ReactNode) | undefined
+  ctx.reflect.provide('appShell', {
+    renderApp: (): ReactNode => {
+      renderApp ??= buildRenderApp({ ctx })
+      return renderApp()
+    },
+  })
+}

+ 8 - 9
packages/client/web/src/app.tsx

@@ -1,8 +1,9 @@
 /**
- * Real-UI assembly closure. Runs only after loader.settled(): the whole
- * layout tree hangs off the built-in 'root' slot (ui-layout registers
- * AppFrame there and renders the child slots internally) — the shell's
- * render is the one ctx-level renderSlot call in the program.
+ * Real-UI assembly closure, invoked by the app-shell plugin once its inject
+ * set is active: the whole layout tree hangs off the built-in 'root' slot
+ * (ui-layout registers AppFrame there and renders the child slots
+ * internally) — the shell's render is the one ctx-level renderSlot call in
+ * the program.
  */
 import type { ReactNode } from 'react'
 import type { Context } from 'cordis'
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
 // Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
 import type {} from '@deepseek-ai/dsh-client-runtime/client'
 
-/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
+/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
 export interface AssemblyDeps {
-  /** Client root context (all plugin services provided). */
+  /** Client context with the assembly's inject set active. */
   ctx: Context
-  /** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
-  requireModule: (spec: string) => unknown
 }
 
 /**
- * Build the renderApp factory handed to AppRoot.
+ * Build the renderApp factory the app-shell plugin provides to AppRoot.
  * @param deps - assembly inputs.
  * @returns factory producing the real UI tree (called once per AppRoot render after settled).
  */

+ 149 - 50
packages/client/web/src/boot.tsx

@@ -1,73 +1,172 @@
 /**
- * Web shell boot — the library face consumed by the apps/web entry (api
- * contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
- * imported; the loader cannot load itself) → seed the module table → render
- * the AppRoot loading page → loader.start() → await settled() → flip the
- * settled signal so AppRoot switches to the real UI in one pass. Load
- * failures reject settled(); AppRoot stays on the loading page listing them
- * (fail loud).
+ * Web shell boot — the kernel face consumed by the apps/web entry. Everything
+ * here is machinery that cannot itself be an entry, and none of it
+ * value-imports a plugin package (web2 shell self-sufficiency rule: the
+ * loading page must work while — especially when — plugins fail).
+ *
+ * Two-stage boot (web2 §0):
+ *   Stage one (module face): build the module system over the host graph
+ *   (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
+ *   — fetch + execute registers factories only; module side effects wait for
+ *   materialization. Prefetch failures are non-fatal here: stage two's
+ *   import path retries the fetch and owns the loud failure.
+ *   Stage two (plugin face): mount the vendored cordis Loader, inject the
+ *   module system as its internal seam (BEFORE any entry exists — the
+ *   bare-import fallback in tree.import must never run in a browser), create
+ *   one loader entry per graph row (tree.import materializes each module),
+ *   let fibers activate on service availability, then loader.await() + a
+ *   full fiber sweep (all ACTIVE, else reject listing who/what/which
+ *   service) → flip the settled signal so AppRoot switches to the real UI in
+ *   one pass.
+ *
+ * Composition lives in the host graph; the shell makes zero composition
+ * decisions (the app-shell assembly is itself a graph entry, the only
+ * shell-own module registered with the module system).
  */
 import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
 import { createRoot } from 'react-dom/client'
-import type { ReactNode } from 'react'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
-import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
+import {
+  createClientModuleLoader,
+  type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
+} from '@deepseek-ai/dsh-client-modules'
+import * as AppShell from './app-shell.ts'
+import { APP_SHELL_ID } from './app-shell.ts'
 import { AppRoot } from './AppRoot.tsx'
-import { buildRenderApp } from './app.tsx'
-import { seedModules } from './seed.ts'
+import { getStaticModules } from './seed.ts'
+import {
+  STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
+} from './loader-status.ts'
 import './base.css'
 
-/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
-function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
-  let value = false
-  const listeners = new Set<() => void>()
-  return {
-    getSnapshot: () => value,
-    subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
-    flip: () => {
-      value = true
-      for (const fn of [...listeners]) fn()
-    },
+/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
+export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
+
+/**
+ * Sweep every loader entry after the tree quiesced: an entry without a fiber
+ * failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
+ * (a required service never arrived — cordis inject waiting has no timeout,
+ * so this sweep is the fail-loud compensation).
+ */
+function assertEntriesActive(ctx: Context): void {
+  const failures: string[] = []
+  for (const entry of ctx.loader.entries()) {
+    const name = entry.options.name
+    if (entry.fiber === undefined) {
+      failures.push(`${name}: import failed (see console for the import error)`)
+      continue
+    }
+    const state = STATE_LABELS[entry.fiber.state]
+    if (state === 'active') continue
+    if (state === 'pending') {
+      const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
+      failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
+    } else {
+      failures.push(`${name}: ${state}`)
+    }
+  }
+  if (failures.length > 0) {
+    throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
   }
 }
 
-/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
-export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
+/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
+async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
+  await Promise.all(graph.entries
+    .filter((row) => row.immediately === true)
+    .map((row) => modules.prefetch(row.id).catch(() => {
+      // Import (stage two) refetches and reports this loudly per entry;
+      // swallowing here keeps one failing prefetch from masking the others.
+    })))
+}
+
+/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
+async function runPluginBoot(
+  ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
+): Promise<void> {
+  await ctx.plugin(Loader)
+  const loader = ctx.loader
+  // Inject the module system BEFORE any entry exists: tree.import falls back
+  // to a bare dynamic import when internal is undefined, which in a browser
+  // is a guaranteed loud failure — correct as a tripwire, never as a path.
+  loader.internal = modules as never
+
+  // Status projection: AppRoot displays fiber truth. Every internal/status
+  // transition under an entry re-projects that entry's row from its ROOT
+  // fiber (child plugin fibers share the same entry).
+  ctx.on('internal/status', (fiber) => {
+    const entry = fiber.entry
+    if (entry === undefined || entry.fiber === undefined) return
+    status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
+  })
+
+  // Entry creation order carries no semantics (fiber inject waiting owns
+  // activation order); creating concurrently lets non-prefetched bundle
+  // fetches parallelize. The app-shell assembly entry is appended by the
+  // kernel: it is shell-own code (host graph rows are all plugin bundles),
+  // and mounting the assembly is not a composition decision — it rides the
+  // same entry lifecycle so the sweep and status cover it uniformly.
+  const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
+  await Promise.all(rows.map(async (name) => {
+    status.set(name, 'loading')
+    const id = await loader.create({ name })
+    // A failed import leaves the entry fiberless (Entry._init logs and
+    // returns); project it as failed — no fiber means no status event.
+    if (loader.resolve(id).fiber === undefined) {
+      status.set(name, 'failed')
+    }
+  }))
+
+  await loader.await()
+  assertEntriesActive(ctx)
+}
 
 /**
- * Mount the web shell into a DOM element and start the plugin load chain.
+ * Mount the web shell into a DOM element and start the two-stage boot chain.
  * @param el - mount point (the app's #root).
- * @param seams - optional loader transport overrides (test environments).
+ * @param seams - optional module transport overrides (test environments).
  * @returns unmount disposer.
  */
 export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
+  const graph = (globalThis as DshWindow).__DSH_BOOT__
+  if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
+
   const ctx = new Context()
-  const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
-  ctx.reflect.provide('loader', loader)
+  const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
+  // The app-shell assembly is the only shell-own module: every other graph
+  // row is a plugin bundle arriving through fetch (web2 single package form).
+  modules.registerStatic(APP_SHELL_ID, AppShell)
+  // Contract C5: the module system is a boot-owned kernel service (ctx.modules).
+  ctx.reflect.provide('modules', modules)
 
-  const settled = settledSignal()
-  // Assemble once on first post-settled render: SessionProvider and the slot
-  // closures must be identity-stable across re-renders.
-  let renderApp: (() => ReactNode) | undefined
-  const renderAppOnce = (): ReactNode => {
-    renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
-    return renderApp()
-  }
+  const status = createLoaderStatusStore()
+  const settled = createSignal(false)
+  const error = createSignal<string | undefined>(undefined)
 
   const root = createRoot(el)
-  root.render(<AppRoot settled={settled} status={loader.status} renderApp={renderAppOnce} />)
-
-  loader.start()
-  loader.settled().then(
-    () => {
-      // The renderer install is a shell-boot act, but ctx.slots exists only
-      // once the runtime plugin loaded — so it lands here, after settled and
-      // before the flip that lets renderApp call renderSlot('root').
-      ctx.slots.install(createSlotRenderer())
-      settled.flip()
-    },
-    () => { /* stay on the loading page; failures render from loader.status */ },
+  root.render(
+    <AppRoot
+      settled={settled}
+      status={status}
+      error={error}
+      renderApp={() => {
+        const shell = ctx.get('appShell')
+        // Unreachable after a clean settle (the app-shell entry is in every graph).
+        if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
+        return shell.renderApp()
+      }}
+    />,
   )
+
+  prefetchImmediateTier(modules, graph)
+    .then(() => runPluginBoot(ctx, modules, graph, status))
+    .then(
+      () => { settled.set(true) },
+      (reason: unknown) => {
+        // Stay on the loading page; surface the sweep report (fail loud).
+        console.error(reason)
+        error.set(reason instanceof Error ? reason.message : String(reason))
+      },
+    )
   return () => { root.unmount() }
 }

+ 11 - 3
packages/client/web/src/index.ts

@@ -1,12 +1,20 @@
 /**
  * Web shell library entry. The shell's product is {@link bootWebShell} —
  * apps/web's vite entry calls it against #root; everything else (AppRoot
- * gate, assembly closure, module-table seed) is internal to the boot chain.
+ * gate, app-shell assembly entry, module-table staticModules, platform constants) is
+ * internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
+ * single source of truth for the tsdown client externals projection.
  * @module @deepseek-ai/dsh-client-web
  */
 
-export { bootWebShell } from './boot.tsx'
+export { bootWebShell, type BootSeams } from './boot.tsx'
 export { AppRoot, type AppRootProps } from './AppRoot.tsx'
 export { buildRenderApp, type AssemblyDeps } from './app.tsx'
 export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
-export { seedModules } from './seed.ts'
+export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
+export { getStaticModules } from './seed.ts'
+export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
+export {
+  STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
+  type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
+} from './loader-status.ts'

+ 111 - 0
packages/client/web/src/loader-status.ts

@@ -0,0 +1,111 @@
+/**
+ * Fiber-state projection vocabulary and the kernel-owned status store for the
+ * boot loading page. The status AppRoot renders is a projection of the real
+ * cordis fiber states (display the truth, not a retelling) — the boot chain
+ * subscribes `internal/status` and recomputes one row per loader entry.
+ *
+ * The store is hand-rolled here because of the shell self-sufficiency rule
+ * (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
+ * package, and the shell kernel must not value-import any plugin package —
+ * the loading page has to work while (and especially when) plugins fail.
+ * @module @deepseek-ai/dsh-client-web/src/loader-status
+ */
+import type { FiberState } from 'cordis'
+
+/**
+ * Value mirror of cordis's `FiberState` const enum: a const enum has no
+ * runtime object to import (and esbuild-based pipelines cannot inline it
+ * across modules), so these values mirror the pinned vendored definition
+ * while retaining its type (same rationale as dsh-tool-cordis's mirror).
+ */
+export const FIBER_STATE = {
+  PENDING: 0 as FiberState.PENDING,
+  LOADING: 1 as FiberState.LOADING,
+  ACTIVE: 2 as FiberState.ACTIVE,
+  FAILED: 3 as FiberState.FAILED,
+  DISPOSED: 4 as FiberState.DISPOSED,
+  UNLOADING: 5 as FiberState.UNLOADING,
+} as const
+
+/** One entry's projected state label (lower-case face of {@link FiberState}). */
+export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
+
+/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
+export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
+  [FIBER_STATE.PENDING]: 'pending',
+  [FIBER_STATE.LOADING]: 'loading',
+  [FIBER_STATE.ACTIVE]: 'active',
+  [FIBER_STATE.FAILED]: 'failed',
+  [FIBER_STATE.DISPOSED]: 'disposed',
+  [FIBER_STATE.UNLOADING]: 'unloading',
+}
+
+/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
+export type LoaderStatus = Record<string, LoaderEntryState>
+
+/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
+export interface KernelSignal<T> {
+  /** Current value (stable reference between changes). */
+  getSnapshot(): T
+  /**
+   * Subscribe to changes.
+   * @param fn - change listener.
+   * @returns the unsubscribe disposer.
+   */
+  subscribe(fn: () => void): () => void
+}
+
+/** Writable one-value signal (settled flag, boot failure report). */
+export interface KernelValueSignal<T> extends KernelSignal<T> {
+  /**
+   * Publish a new value and notify subscribers.
+   * @param next - the new value.
+   */
+  set(next: T): void
+}
+
+/**
+ * Create a writable kernel signal.
+ * @param init - initial value.
+ * @returns the signal.
+ */
+export function createSignal<T>(init: T): KernelValueSignal<T> {
+  let value = init
+  const listeners = new Set<() => void>()
+  return {
+    getSnapshot: () => value,
+    subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
+    set: (next) => {
+      value = next
+      for (const fn of [...listeners]) fn()
+    },
+  }
+}
+
+/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
+export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
+  /**
+   * Project one entry's state (copy-on-write so getSnapshot references only
+   * change on writes — useSyncExternalStore contract).
+   * @param id - entry name.
+   * @param state - projected fiber state.
+   */
+  set(id: string, state: LoaderEntryState): void
+}
+
+/**
+ * Create the boot status store.
+ * @returns the store (empty until the boot chain projects rows).
+ */
+export function createLoaderStatusStore(): LoaderStatusStore {
+  let value: LoaderStatus = {}
+  const listeners = new Set<() => void>()
+  return {
+    getSnapshot: () => value,
+    subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
+    set: (id, state) => {
+      value = { ...value, [id]: state }
+      for (const fn of [...listeners]) fn()
+    },
+  }
+}

+ 20 - 0
packages/client/web/src/platform.ts

@@ -0,0 +1,20 @@
+/**
+ * Platform singletons the shell shares into the module table.
+ * Single source of truth (design §3.3, contract C1): seed keys = tsdown
+ * client externals = the shared surface. The three projections import this
+ * module — the seed table ({@link ../seed.ts}), the tsdown client preset's
+ * external judgement (packages/client/tsdown.client.ts), and the vite alias
+ * check — so the list cannot drift between them.
+ * @module @deepseek-ai/dsh-client-web/src/platform
+ */
+
+/** The module specifiers the shell shares into the frozen module table. */
+export const PLATFORM_MODULES = [
+  'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
+  '@deepseek-ai/dsh-client-ui-slots',
+  '@deepseek-ai/dsh-client-web-react',
+  '@deepseek-ai/dsh-client-ui-primitives',
+] as const
+
+/** One platform module specifier (a seed-table key). */
+export type PlatformModule = (typeof PLATFORM_MODULES)[number]

+ 14 - 10
packages/client/web/src/seed.ts

@@ -1,10 +1,10 @@
 /**
- * Pure-library module-table seed. These are the ONLY entities statically
- * built into the shell bundle besides the loader machinery — every plugin
- * (including the infrastructure four) arrives as a dynamic bundle and
- * resolves its externals against this table through the loader's require.
- * Keys must match the tsdown client preset's external specifiers
- * (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
+ * Platform-singleton module-table. These are the ONLY entities the shell
+ * shares into the frozen module table — fetch bundles resolve their externals
+ * against exactly this set through the loader's require. Keys come from the
+ * platform constant module ({@link ./platform.ts}, contract C1: single source
+ * of truth with the tsdown client externals); values stay shell-static
+ * imports so every bundle sees the same instance.
  */
 import * as React from 'react'
 import * as ReactJsxRuntime from 'react/jsx-runtime'
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
 import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
 import * as WebReact from '@deepseek-ai/dsh-client-web-react'
 import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PlatformModule } from './platform.ts'
 
 /**
- * Build the seed table handed to the loader machinery at boot.
- * @returns module specifier → export-surface entity.
+ * Build the static table handed to the module loader at boot.
+ * @returns module specifier → export-surface entity (one entry per platform word).
  */
-export function seedModules(): Record<string, unknown> {
+export function getStaticModules(): Record<string, unknown> {
+  // The satisfies pin is the projection contract: a word added to
+  // PLATFORM_MODULES without a static import here (or vice versa) fails to
+  // compile instead of drifting into a runtime require miss.
   return {
     'react': React,
     'react/jsx-runtime': ReactJsxRuntime,
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
     '@deepseek-ai/dsh-client-ui-slots': UiSlots,
     '@deepseek-ai/dsh-client-web-react': WebReact,
     '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
-  }
+  } satisfies Record<PlatformModule, unknown>
 }

+ 26 - 25
packages/client/web/tests/app-root.spec.tsx

@@ -1,42 +1,33 @@
 // @vitest-environment jsdom
 /**
  * AppRoot boot-gate smoke: loading page until the settled signal flips (status
- * alone never opens the gate), fail-loud plugin list, one-pass switch to the
- * real UI. The full browser chain (real loader + bundles) is the e2e's job;
- * this pins the shell-owned gate semantics.
+ * alone never opens the gate), fail-loud entry list + boot failure report,
+ * one-pass switch to the real UI. The full browser chain (real module system
+ * + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
+ * gate semantics. Stores are the kernel-own signals production boot uses
+ * (shell self-sufficiency: the loading page depends on no plugin package).
  */
 import { afterEach, describe, expect, it } from 'vitest'
 import { act, cleanup, render } from '@testing-library/react'
 
 afterEach(cleanup)
-// The snapshot-store engine lives with runtime now; the status-store stub
-// uses the same channel production code does.
-import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
 import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
-
-function signal(): ObservableSnapshot<boolean> & { flip: () => void } {
-  let value = false
-  const listeners = new Set<() => void>()
-  return {
-    getSnapshot: () => value,
-    subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
-    flip: () => { value = true; for (const fn of [...listeners]) fn() },
-  }
-}
+import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
 
 function mount() {
-  const settled = signal()
-  const status = createSnapshotStore<LoaderStatus>({})
+  const settled = createSignal(false)
+  const error = createSignal<string | undefined>(undefined)
+  const status = createLoaderStatusStore()
   let renders = 0
   const utils = render(
     <AppRoot
       settled={settled}
       status={status}
+      error={error}
       renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
     />,
   )
-  return { settled, status, counts: () => renders, ...utils }
+  return { settled, status, error, counts: () => renders, ...utils }
 }
 
 describe('AppRoot', () => {
@@ -50,24 +41,34 @@ describe('AppRoot', () => {
   it('all-active status alone does not open the gate (settled signal is the only key)', () => {
     const { status, queryByTestId } = mount()
     act(() => {
-      status.update((d) => { d['a'] = 'active'; d['b'] = 'active' })
+      status.set('a', 'active')
+      status.set('b', 'active')
     })
     expect(queryByTestId('real-ui')).toBeNull()
   })
 
-  it('lists failed plugins and stays on the loading page', () => {
+  it('lists failed entries and stays on the loading page', () => {
     const { status, getByText, queryByTestId } = mount()
     act(() => {
-      status.update((d) => { d['@deepseek-ai/dsh-client-ui-theme'] = 'failed'; d['ok'] = 'active' })
+      status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
+      status.set('ok', 'active')
     })
     expect(getByText('Failed to load plugins')).toBeTruthy()
-    expect(getByText('@deepseek-ai/dsh-client-ui-theme')).toBeTruthy()
+    expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
+    expect(queryByTestId('real-ui')).toBeNull()
+  })
+
+  it('renders the boot failure report even when no entry projected failed', () => {
+    const { error, getByText, queryByTestId } = mount()
+    act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
+    expect(getByText('Failed to load plugins')).toBeTruthy()
+    expect(getByText(/waiting for service/)).toBeTruthy()
     expect(queryByTestId('real-ui')).toBeNull()
   })
 
   it('flipping settled switches to the real UI in one pass', () => {
     const { settled, getByTestId, queryByText, counts } = mount()
-    act(() => { settled.flip() })
+    act(() => { settled.set(true) })
     expect(getByTestId('real-ui')).toBeTruthy()
     expect(queryByText('HARNESS')).toBeNull()
     expect(counts()).toBe(1)

+ 0 - 233
packages/client/web/tests/boot.spec.tsx

@@ -1,233 +0,0 @@
-// @vitest-environment jsdom
-/**
- * bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
- * the loader's <script> execute path runs for real): fetch is stubbed to
- * serve fake bundle text, everything else is production code — seeded module
- * table, DSHClientProxy handoff, inject topology, renderer install after
- * settled, the one-line renderSlot('root') shell, and the fail-loud paths —
- * through the loader's fetch/execute seams (jsdom's <script> vm context
- * cannot reach the test window, so execute is indirect eval). The fake
- * runtime is the REAL SlotsService mounted by the real runtime plugin shape;
- * full-fidelity plugin content belongs to the apps/web e2e.
- */
-import { afterEach, describe, expect, it } from 'vitest'
-import { act } from '@testing-library/react'
-import { bootWebShell } from '@deepseek-ai/dsh-client-web'
-import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-
-interface BootWindow extends Window {
-  __DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
-  DSHClientProxy?: unknown
-  __TEST_SLOTS_SERVICE__?: unknown
-  __TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
-}
-const win = window as unknown as BootWindow
-
-/**
- * Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
- * install/renderSlot) plus a minimal sessions face for the renderer host.
- * The runtime package is not a seeded library (in production it arrives as a
- * bundle), so the spec hands the real class in through a window global — the
- * plugin body and everything downstream stay production code.
- */
-const RUNTIME_STUB = `
-window.DSHClientProxy.loadPlugin({
-  id: 'fake-runtime',
-  factory: (require) => {
-    const SlotsService = window.__TEST_SLOTS_SERVICE__
-    const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
-    return {
-      apply: (ctx) => {
-        ctx.plugin(SlotsService)
-        const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
-        ctx.provide('sessions', {
-          list,
-          cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
-        })
-      },
-    }
-  },
-})`
-
-/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
- *  child, seat a store factory, expose the store round trip as a probe. */
-const LAYOUT_STUB = `
-window.DSHClientProxy.loadPlugin({
-  id: 'fake-layout',
-  factory: (require) => {
-    const React = require('react')
-    const { defineStore } = window.__TEST_RUNTIME_STORE__
-    return {
-      inject: ['slots'],
-      apply: (ctx) => {
-        const createProbeStore = () => defineStore({
-          init: () => ({ sidebar: 300, details: 360 }),
-          actions: {
-            setSidebar: (d, px) => { d.sidebar = px },
-            setDetails: (d, px) => { d.details = px },
-          },
-        })
-        ctx.slots.register({
-          name: 'root',
-          children: { 'probe.child': { kind: 'single', scope: 'root' } },
-          store: createProbeStore,
-        }, (props) => {
-          const sw = props.useStore((st) => st.sidebar)
-          const dw = props.useStore((st) => st.details)
-          return React.createElement('div', {
-            'data-testid': 'fake-frame',
-            'data-widths': sw + 'x' + dw,
-            onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
-          }, props.renderSlot('probe.child', {}))
-        })
-      },
-    }
-  },
-})`
-
-// The shell assembly requires the layout surface under its production id.
-const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
-
-/** Loader seams: serve fake bundle text and execute it via indirect eval (jsdom's <script> vm context cannot see the test window). */
-function seams(bundles: Record<string, string>) {
-  return {
-    fetchBundle: (url: string): Promise<string> => {
-      const hit = Object.keys(bundles).find((b) => url.endsWith(b))
-      if (hit === undefined) return Promise.reject(new Error(`bundle fetch ${url} answered 404`))
-      return Promise.resolve(bundles[hit]!)
-    },
-    executeBundle: (code: string): void => {
-      (0, eval)(code)
-    },
-  }
-}
-
-function mountPoint(): HTMLElement {
-  const el = document.createElement('div')
-  document.body.appendChild(el)
-  return el
-}
-
-async function flushLoader(): Promise<void> {
-  // fetch + per-plugin apply chain across macrotask turns; a few settle it.
-  for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
-}
-
-function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
-  return [
-    { id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
-    { id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
-  ]
-}
-
-function fakeBundles(): Record<string, string> {
-  return {
-    '/plugins/fake-runtime.js': RUNTIME_STUB,
-    '/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
-  }
-}
-
-afterEach(() => {
-  delete win.__DSH_BOOT__
-  delete win.DSHClientProxy
-  delete win.__TEST_SLOTS_SERVICE__
-  delete win.__TEST_RUNTIME_STORE__
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('script').forEach((s) => { s.remove() })
-  document.title = ''
-})
-
-/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
-function seedSlotsService(): void {
-  win.__TEST_SLOTS_SERVICE__ = SlotsService
-  win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
-}
-
-describe('bootWebShell (real loader + real script execution)', () => {
-  it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
-    win.__DSH_BOOT__ = { plugins: bootPlugins() }
-    seedSlotsService()
-    const el = mountPoint()
-    document.title = 'DeepSeek Harness'
-    let unmount: (() => void) | undefined
-    act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
-    expect(el.textContent).toContain('HARNESS')
-    expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
-
-    await flushLoader()
-    expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
-    expect(el.textContent).not.toContain('HARNESS')
-    expect(document.title).toBe('S1 — DeepSeek Harness')
-
-    act(() => { unmount!() })
-    expect(el.childElementCount).toBe(0)
-    expect(document.title).toBe('DeepSeek Harness')
-  })
-
-  it('store seat round-trips through the entry props (useStore + actions)', async () => {
-    win.__DSH_BOOT__ = { plugins: bootPlugins() }
-    seedSlotsService()
-    const el = mountPoint()
-    act(() => { bootWebShell(el, seams(fakeBundles())) })
-    await flushLoader()
-    const frame = el.querySelector('[data-testid="fake-frame"]')
-    expect(frame).not.toBeNull()
-    // Width write/read round trip through the framework-delivered store share.
-    expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
-    act(() => { (frame as HTMLElement).click() })
-    expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
-  })
-
-  it('fail loud: a 404 bundle keeps the loading page and lists the plugin id', async () => {
-    win.__DSH_BOOT__ = { plugins: [{ id: 'absent-plugin', url: '/plugins/absent.js', inject: [] }] }
-    const el = mountPoint()
-    act(() => { bootWebShell(el, seams({})) })
-    await flushLoader()
-    expect(el.textContent).toContain('Failed to load plugins')
-    expect(el.textContent).toContain('absent-plugin')
-    expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
-  })
-
-  it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
-    // Runtime loads (slots service present, renderer installed) but no layout
-    // entry ever registers into 'root' — the ctx-level renderSlot must throw.
-    win.__DSH_BOOT__ = {
-      plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
-    }
-    seedSlotsService()
-    const el = mountPoint()
-    // React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
-    const consoleError = console.error
-    console.error = () => {}
-    try {
-      act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
-      let thrown: unknown
-      try {
-        await flushLoader()
-      } catch (error) {
-        thrown = error
-      }
-      expect(String(thrown)).toMatch(/'root' has no registration/)
-    } finally {
-      console.error = consoleError
-    }
-  })
-})
-
-describe('buildRenderApp — assembly contract', () => {
-  it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
-    const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
-    const { Context } = await import('cordis')
-    const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
-    const ctx = new Context()
-    const fiber = ctx.plugin(SlotsService)
-    await fiber.await()
-    ctx.provide('sessions', {
-      list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
-    })
-    const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
-    expect(renderApp).toBeTypeOf('function')
-    // No renderer installed: the one-line shell must surface the boot-order error.
-    expect(() => renderApp()).toThrow(/renderer not installed/)
-  })
-})

+ 6 - 9
packages/client/web/tsconfig.json

@@ -12,25 +12,22 @@
       "path": "../../../vendor/cordis"
     },
     {
-      "path": "../ui-slots"
+      "path": "../../../vendor/loader"
     },
     {
-      "path": "../ui-primitives"
-    },
-    {
-      "path": "../web-react"
+      "path": "../modules"
     },
     {
-      "path": "../connection"
+      "path": "../ui-slots"
     },
     {
-      "path": "../runtime"
+      "path": "../ui-primitives"
     },
     {
-      "path": "../ui-theme"
+      "path": "../web-react"
     },
     {
-      "path": "../ui-layout"
+      "path": "../runtime"
     },
     {
       "path": "../../support/invariants"

+ 2 - 1
tsconfig.base.json

@@ -102,9 +102,10 @@
       "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
       "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
       "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
+      "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],
+      "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
       "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
       "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
-      "@deepseek-ai/dsh-client-runtime/loader": ["./packages/client/runtime/src/client/loader"],
       "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
       "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
       "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],

+ 2 - 0
tsconfig.client.json

@@ -27,6 +27,8 @@
     { "path": "./packages/client/ui-slots" },
     { "path": "./packages/client/ui-primitives" },
     { "path": "./packages/client/web-react" },
+    { "path": "./packages/client/modules" },
+    { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection" },
     { "path": "./packages/client/runtime" },
     { "path": "./packages/client/ui-layout" },