1
0
Эх сурвалжийг харах

build(client): enforce client package boundaries

imccyu 2 долоо хоног өмнө
parent
commit
aa03eff500
68 өөрчлөгдсөн 2401 нэмэгдсэн , 525 устгасан
  1. 4 6
      apps/web/package.json
  2. 14 14
      apps/web/vite.config.ts
  3. 35 0
      knip.json
  4. 2 1
      package.json
  5. 40 1
      packages/client/AGENTS.md
  6. 14 8
      packages/client/connection/package.json
  7. 4 5
      packages/client/locale/package.json
  8. 3 1
      packages/client/modules/package.json
  9. 9 7
      packages/client/modules/src/client/index.ts
  10. 90 21
      packages/client/modules/src/client/manifest.ts
  11. 28 25
      packages/client/modules/src/client/system.ts
  12. 127 28
      packages/client/modules/src/index.ts
  13. 4 1
      packages/client/modules/tsdown.config.ts
  14. 24 13
      packages/client/runtime/package.json
  15. 300 42
      packages/client/tsdown.client.ts
  16. 1 4
      packages/client/ui-agent-preset/package.json
  17. 1 4
      packages/client/ui-commands/package.json
  18. 3 5
      packages/client/ui-deliverables/package.json
  19. 1 4
      packages/client/ui-directory-picker-browse/package.json
  20. 1 3
      packages/client/ui-directory-picker-native/package.json
  21. 5 4
      packages/client/ui-goal/package.json
  22. 1 4
      packages/client/ui-input-trigger/package.json
  23. 2 6
      packages/client/ui-jobs/package.json
  24. 1 3
      packages/client/ui-layout/package.json
  25. 1 4
      packages/client/ui-message-feedback/package.json
  26. 6 8
      packages/client/ui-model-selection/package.json
  27. 1 4
      packages/client/ui-plan/package.json
  28. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  29. 0 0
      packages/client/ui-primitives/README.md
  30. 0 0
      packages/client/ui-primitives/README.zh.md
  31. 1 0
      packages/client/ui-primitives/package.json
  32. 5 30
      packages/client/ui-primitives/tsdown.config.ts
  33. 3 5
      packages/client/ui-settings-general/package.json
  34. 1 4
      packages/client/ui-settings-plugin-inventory/package.json
  35. 1 4
      packages/client/ui-settings-plugins/package.json
  36. 2 1
      packages/client/ui-settings/package.json
  37. 1 3
      packages/client/ui-sidebar/package.json
  38. 1 4
      packages/client/ui-skill/package.json
  39. 2 2
      packages/client/ui-slots/tsdown.config.ts
  40. 2 6
      packages/client/ui-subagent/package.json
  41. 4 5
      packages/client/ui-theme/package.json
  42. 1 4
      packages/client/ui-tool/package.json
  43. 1 3
      packages/client/ui-trajectory/package.json
  44. 11 9
      packages/client/ui-user-questions/package.json
  45. 2 6
      packages/client/ui-workflow-run/package.json
  46. 2 3
      packages/client/ui-workspace/package.json
  47. 6 8
      packages/client/web/package.json
  48. 1 1
      packages/client/web/src/index.ts
  49. 5 0
      packages/client/web/src/platform.ts
  50. 5 30
      packages/client/web/tsdown.config.ts
  51. 1 3
      packages/extensions/cordis-client-runner/package.json
  52. 1 4
      packages/extensions/ui-cordis/package.json
  53. 1 4
      packages/session-query/session-log-export/package.json
  54. 122 118
      pnpm-lock.yaml
  55. 7 0
      scripts/check-workspace-constraints.ts
  56. 47 11
      scripts/client-bundle-purity.spec.ts
  57. 26 1
      scripts/dev-web.spec.ts
  58. 137 13
      scripts/dev-web.ts
  59. 33 5
      scripts/publint-all.spec.ts
  60. 88 8
      scripts/publint-all.ts
  61. 2 2
      scripts/rescope-vendor.ts
  62. 9 0
      scripts/run-gates.spec.ts
  63. 2 0
      scripts/run-gates.ts
  64. 317 0
      scripts/verify-client-packages.spec.ts
  65. 819 0
      scripts/verify-client-packages.ts
  66. 1 0
      scripts/verify-package-readme-model-experience.ts
  67. 3 0
      tsconfig.base.json
  68. 4 0
      vitest.config.ts

+ 4 - 6
apps/web/package.json

@@ -22,19 +22,15 @@
   "scripts": {
     "build": "vite build",
     "dev": "vite",
-    "watch": "vite build --watch"
+    "watch": "vite build --watch --no-emptyOutDir"
   },
   "license": "MIT",
-  "dependencies": {
-    "@deepseek-ai/dsh-client-web": "workspace:^",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-group": "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-web": "workspace:^",
     "@deepseek-ai/dsh-cmdline": "workspace:^",
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@types/node": "^22.0.0",
@@ -42,6 +38,8 @@
     "@types/react-dom": "~18.3.0",
     "@vitejs/plugin-react": "^4.0.0",
     "playwright": "^1.49.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
     "typescript": "^6.0.3",
     "vite": "^6.0.0",
     "vitest": "^4.1.8",

+ 14 - 14
apps/web/vite.config.ts

@@ -127,22 +127,22 @@ export default defineConfig({
     },
   },
   resolve: {
-    // 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 normal package entry is aliased — plugin packages are
-    // NEVER bundled here (shell self-sufficiency — see
-    // packages/client/web/README.md); they arrive as runtime
-    // bundles through the client module system. Order matters — subpath
-    // aliases must win over bare-name prefixes.
+    // One instance per shared npm identity: a bare specifier otherwise resolves
+    // from the importer's directory, so a diverging range ships a second React
+    // and splits hook and element identity. Entries are package ids — they cover
+    // react/jsx-runtime and react-dom/client — and resolve from this package's
+    // node_modules, so react must stay a devDependency here and any watcher must
+    // run vite from this directory (scripts/dev-web.ts). Workspace packages need
+    // no entry: pnpm links each of them to a single directory.
+    dedupe: ['react', 'react-dom'],
+    // Workspace packages are consumed as built lib products: each resolves
+    // through its own package.json exports from the importer's directory, and
+    // CSS still rides Vite's pipeline because the client build preset emits it
+    // beside the bundle. Plugin packages never enter this graph; they arrive as
+    // runtime bundles through the client module system. The remaining alias
+    // browserizes the vendored Cordis Loader's only Node import.
     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.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-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
     ],
   },
   define: {

+ 35 - 0
knip.json

@@ -708,6 +708,9 @@
       "project": [
         "src/**/*.ts",
         "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-connection"
       ]
     },
     "apps/web": {
@@ -787,6 +790,38 @@
       "ignoreDependencies": [
         "@deepseek-ai/.+"
       ]
+    },
+    "packages/client/locale": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-connection"
+      ]
+    },
+    "packages/client/ui-agent-preset": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-connection"
+      ]
+    },
+    "packages/client/ui-conversation": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-api-remotes"
+      ]
+    },
+    "packages/client/ui-permission-presets": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-connection"
+      ]
+    },
+    "packages/client/ui-settings-general": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-connection"
+      ]
+    },
+    "packages/client/ui-theme": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-connection"
+      ]
     }
   }
 }

+ 2 - 1
package.json

@@ -97,6 +97,7 @@
     "verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
     "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts",
     "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
+    "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",
@@ -126,7 +127,7 @@
     "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
     "constraints": "tsx scripts/check-workspace-constraints.ts",
     "doc-sync": "tsx scripts/run-gates.ts doc-sync",
-    "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
+    "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-packages && pnpm run verify-vendored-links",
     "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts",
     "release:dsh": "tsx scripts/release/bump.ts --family dsh",
     "release:vendor": "tsx scripts/release/bump.ts --family vendor",

+ 40 - 1
packages/client/AGENTS.md

@@ -54,6 +54,44 @@ Non-negotiables across the layers:
 - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
 - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
 
+## Dependency declaration
+
+Npm sections describe installation and development relationships; each build face independently decides what its artifact contains. [`verify-client-packages`](../../scripts/verify-client-packages.ts) checks the client-specific rules and can repair unambiguous manifest drift with `--fix`.
+
+1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract.
+2. **A dynamic package declares internal dynamic relationships as peer plus dev.** Production source imports, re-exports, module augmentations, and type-only references to an `@deepseek-ai/dsh-*` package count, as does a package named by `dsh.client.inject`. A test-only internal dependency stays dev-only.
+3. **Static client inputs are dev-only for a dynamic consumer.** A package without `dsh.client`, plus the React modules seeded by the web shell, belongs only in the consumer's `devDependencies`; it never belongs in that dynamic package's `dependencies` or `peerDependencies`. `packages/client/web` likewise keeps Loader, modules, and static UI inputs as development inputs; Cordis remains peer plus dev.
+4. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. A dynamic package never puts an `@deepseek-ai/dsh-*` package in `dependencies`.
+5. **Every peer has a matching development range.** npm dependency and peer cycles are allowed; only the synchronous module-request graph has the separate acyclicity rule below.
+6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
+7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
+
+## Shared modules and the module graph
+
+A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot.
+
+1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, runtime, `ui-primitives`, or `ui-slots` in package manifests.
+2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row.
+3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request.
+4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol.
+5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles.
+
+### The module graph sits below cordis DI
+
+Three declarations read like dependency edges and none is interchangeable: Cordis service `inject`, module-graph `external`, and `dsh.client.inject` — the informational package-name edges of the [new-package checklist](#new-plugin-package-checklist).
+
+| | Cordis service `inject` | module graph `external` |
+|---|---|---|
+| Unit | service name | module specifier |
+| Timing | runtime; the fiber waits | materialization; the `require` handed to a factory is synchronous and cannot wait |
+| Unsatisfied | stays PENDING, with no timeout | throws on the spot |
+| Who may satisfy it | any plugin providing that service, replaceable | the single module identity, not replaceable |
+| Cycles | allowed | rejected |
+
+The seam is `loader.internal = modules`: cordis reaches plugin code through `EntryTree.import`, so every module request must be satisfiable before cordis can order activation above it. Script-tag order is therefore the module graph's topological order — providers before consumers — computed by the modules node half and injected by the host. The two orders can run opposite: a provider that injects services loads its script first and activates last.
+
+`packages/client/web` is not a Loader entry. Its static imports seed `PLATFORM_MODULES`; parser-preloaded dynamic rows remain ordinary Loader entries and ordinary `lib/client.js` artifacts.
+
 ## Conversation Node discipline
 
 - A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md).
@@ -93,9 +131,10 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
 
 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
-3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
+3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
 5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
+6. **Declaration decisions**, each settled by [dependency declaration](#dependency-declaration) and [shared modules](#shared-modules-and-the-module-graph): does the package ship a `./client` export; which non-baseline value imports require `dsh.client.external`; which dynamic value dependencies are peer plus dev; which static compile inputs are dev-only; and whether `files` covers every relative runtime import and emitted asset.
 
 ## New component checklist
 

+ 14 - 8
packages/client/connection/package.json

@@ -38,12 +38,6 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/dsh-attachment": "workspace:^",
-    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
-    "@deepseek-ai/dsh-commands": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "ws": "^8.21.0"
   },
@@ -56,12 +50,24 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/ws": "^8.18.1",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
   }
 }

+ 4 - 5
packages/client/locale/package.json

@@ -47,11 +47,9 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-settings": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -63,10 +61,11 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^"
   },
   "dependencies": {
-    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   },
   "files": [

+ 3 - 1
packages/client/modules/package.json

@@ -55,6 +55,8 @@
   ],
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^"
   }
 }

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

@@ -2,21 +2,23 @@
  * Browser half (the standard `./client` export): the module-system class and
  * wire contract, plus the enrollment plugin face. The module system itself is
  * built by the shell kernel BEFORE cordis exists (the bootstrap exception —
- * the mechanism that loads plugins cannot arrive through
- * itself); the plugin face only enrolls that pre-existing instance by
- * providing it as `ctx.modules`. The kernel statically registers this module,
- * so the graph row for this package never triggers a real fetch — arrival is
- * a no-op against the already-registered entry.
+ * the mechanism that loads plugins cannot arrive through itself). The host
+ * parser-preloads this ordinary client bundle into the handoff queue; the
+ * kernel claims and materializes that handoff, constructs the system, and
+ * registers the same exports for this package's graph row. The plugin face
+ * only enrolls that pre-existing instance by providing it as `ctx.modules`.
  * @module @deepseek-ai/dsh-client-modules/client
  */
 import type { Context } from '@deepseek-ai/cordis'
 import type { DshWindow } from './manifest.ts'
 
 export { ClientModuleSystem } from './system.ts'
-export { parseBootManifest } from './manifest.ts'
+export { parseBootManifest, stripClientSuffix } from './manifest.ts'
 export type {
   BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
-  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
+  ClientModuleHandoffQueue, ClientModuleHandoffSink, ClientModuleHandoffTarget,
+  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
+  WebBootEntry, WebBootGraph,
 } from './manifest.ts'
 
 /**

+ 90 - 21
packages/client/modules/src/client/manifest.ts

@@ -16,13 +16,13 @@
  * so load order needs no external sequencing.
  *
  * Resolution branch order (import): seed word → shell instance; memoized
- * record → exports; static registry (shell-own modules, e.g. app-shell) →
+ * record → exports; static registry (pre-materialized bootstrap modules) →
  * module; registered factory → materialize; graph row → load + 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 load branch: loading is async,
- * so only already-registered bundles can be required — and cross-plugin value
- * imports are a build error anyway.
+ * 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 load branch. Loading is async, so a requested dynamic package must have
+ * registered its factory before a consumer materializes.
  *
  * This file is the browser-safe contract face (zero node imports): the
  * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
@@ -45,7 +45,9 @@ declare module '@deepseek-ai/cordis' {
  * single source: the host node half (package root) produces this same shape.
  * `immediately` marks stage-one prefetch; `inject` is informational graph
  * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation).
+ * declaration and reach fibers through entry creation). `external` carries
+ * module-graph edges: unlike `inject`, they constrain code arrival because
+ * `require` is synchronous (see {@link WebBootGraph.entries}).
  */
 export interface WebBootEntry {
   /** Entry name == package name. */
@@ -58,13 +60,19 @@ export interface WebBootEntry {
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean
+  /** Non-baseline module specifiers this row requests; omitted when it requests none. */
+  external?: string[]
 }
 
 /** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
 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). */
+  /**
+   * Composed entries in module-graph order — a dynamic package row precedes
+   * rows whose `external` requests that package. Cordis activation order is
+   * unrelated and remains owned by fiber service waiting.
+   */
   entries: WebBootEntry[]
 }
 
@@ -76,6 +84,8 @@ export interface BootModuleRow {
   url: string
   /** Bundle content hash. */
   rev: string
+  /** Module specifiers this row requests from the module table ([] when the wire omits them). */
+  external: string[]
 }
 
 /** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
@@ -98,6 +108,36 @@ export interface BootManifest {
   plugins: BootPluginRow[]
 }
 
+/**
+ * Validate an optional string-array field read from a `dsh.client` declaration
+ * or from the boot wire.
+ * @param subject - diagnostic prefix naming the package or the wire row.
+ * @param field - field name as it appears in the diagnostic.
+ * @param value - the raw field value.
+ * @returns the validated array, or undefined when the field is absent.
+ * @throws {Error} when the value is present but is not an array of strings.
+ */
+export function optionalStringArray(subject: string, field: string, value: unknown): string[] | undefined {
+  if (value === undefined) return undefined
+  if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
+    throw new Error(`client-modules: ${subject} ${field} must be a string array`)
+  }
+  return value as string[]
+}
+
+/**
+ * Normalize a module specifier onto the graph row that owns it: a plugin bundle
+ * IS its package's client half, so `<id>/client` (the exports subpath external
+ * bundles emit) and the bare package name resolve to the same exports. Both the
+ * require path and graph composition normalize here, which is what lets each
+ * importing package request the subpath its own code imports.
+ * @param spec - module specifier as a bundle requires it or a declaration spells it.
+ * @returns the specifier with a trailing `/client` removed.
+ */
+export function stripClientSuffix(spec: string): string {
+  return spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
+}
+
 /**
  * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
  * a missing or malformed graph throws (the shell shows the loud failure —
@@ -127,16 +167,21 @@ export function parseBootManifest(wire: unknown): BootManifest {
     if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
       throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
     }
-    if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
-      throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
-    }
+    const subject = `boot manifest entry ${where}`
+    const inject = optionalStringArray(subject, 'inject', row.inject)
+    const external = optionalStringArray(subject, 'external', row.external)
     if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
       throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
     }
-    modules.push({ id: row.id, url: row.url, rev: row.rev })
+    modules.push({
+      id: row.id,
+      url: row.url,
+      rev: row.rev,
+      external: external === undefined ? [] : [...external],
+    })
     plugins.push({
       id: row.id,
-      inject: row.inject === undefined ? [] : [...row.inject as string[]],
+      inject: inject === undefined ? [] : [...inject],
       immediately: row.immediately === true,
     })
   }
@@ -155,12 +200,36 @@ export interface ClientPluginHandoff {
   factory: (require: (spec: string) => unknown) => Record<string, unknown>
 }
 
+/** Inline HTML queue installed before a preloaded client bundle executes. */
+export interface ClientModuleHandoffQueue {
+  /** Discriminant that lets the module system distinguish the bootstrap queue from a live sink. */
+  mode: 'queue'
+  /**
+   * Handoffs received before {@link ClientModuleSystem} exists; the kernel
+   * claims modules before the rest drain.
+   */
+  handoffs: ClientPluginHandoff[]
+  /** Append one preloaded bundle handoff for later adoption. */
+  load(handoff: ClientPluginHandoff): void
+}
+
+/** Live registration sink installed by {@link ClientModuleSystem}. */
+export interface ClientModuleHandoffSink {
+  /** Discriminant used to reject a second module-system boot. */
+  mode: 'live'
+  /** Register one bundle factory immediately. */
+  load(handoff: ClientPluginHandoff): void
+}
+
+/** Bootstrap queue before module-system construction, then the live registration sink. */
+export type ClientModuleHandoffTarget = ClientModuleHandoffQueue | ClientModuleHandoffSink
+
 /** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
 export interface DshWindow {
   /** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
   __DSH_BOOT__?: unknown
-  /** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor. */
-  __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
+  /** Bundle handoff target: an HTML bootstrap queue, then the live module-system sink. */
+  __ModuleLoader__?: ClientModuleHandoffTarget
   /**
    * Kernel handoff slot: the shell kernel stores the instance here right
    * after construction (before cordis exists) so the `./client` wrapper
@@ -174,7 +243,7 @@ export interface DshWindow {
 export interface ClientModuleRecord {
   /** Module id (entry name / package name). */
   id: string
-  /** Materialized exports (`module.exports` from a factory, or a statically registered shell module). */
+  /** Materialized exports (`module.exports` from a factory or bootstrap registration). */
   exports: unknown
   /** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
   styles: string[]
@@ -203,16 +272,16 @@ export interface ClientModuleLoader {
    */
   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.
+   * Register an already-materialized bootstrap module whose handoff was
+   * removed from the HTML queue before this system was constructed.
+   * @param id - graph entry name.
+   * @param module - the materialized module exports.
    */
   registerStatic(id: string, module: unknown): void
   /**
    * Stage-one arrival: load the entry's script to register its factory (no
    * materialization — module side effects wait for import).
-   * No-op for static-registered ids and ids whose factory is already
+   * No-op for bootstrap-registered ids and ids whose factory is already
    * registered; concurrent calls share one in-flight task. To force a fresh
    * load (HMR), {@link invalidate} first.
    * @param id - graph entry name.

+ 28 - 25
packages/client/modules/src/client/system.ts

@@ -4,9 +4,10 @@
  * documented on the public interfaces in `./manifest.ts`; this file owns the
  * state tables and the load/materialize machinery.
  */
+import { stripClientSuffix } from './manifest.ts'
 import type {
   BootModuleRow, ClientModuleLoader, ClientModuleRecord,
-  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
+  ClientModuleHandoffSink, ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
 } from './manifest.ts'
 
 /** Default bundle-load hook: same-origin external classic script. */
@@ -25,14 +26,6 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
   document.head.append(el)
 })
 
-/**
- * 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
- * exports, 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;
@@ -84,15 +77,26 @@ export class ClientModuleSystem implements ClientModuleLoader {
     }
 
     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, handoff.factory)
-      },
+    const queued = win.__ModuleLoader__
+    if (queued !== undefined && queued.mode !== 'queue') {
+      throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
+    }
+    const sink: ClientModuleHandoffSink = {
+      mode: 'live',
+      load: (handoff) => { this.register(handoff) },
+    }
+    // Replace first: a bundle that executes while queued handoffs are draining
+    // must register against the live sink rather than append behind the drain.
+    win.__ModuleLoader__ = sink
+    for (const handoff of queued?.handoffs ?? []) sink.load(handoff)
+  }
+
+  /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
+  private register(handoff: ClientPluginHandoff): void {
+    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, handoff.factory)
   }
 
   /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
@@ -134,10 +138,9 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   /**
    * 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).
+   * record → registered factory. Fetching is async and therefore unreachable
+   * from here; an external dynamic package must have arrived before its
+   * consumer materializes.
    */
   private makeRequire(edges: Set<string>): (spec: string) => unknown {
     return (spec: string): unknown => {
@@ -149,8 +152,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
       if (record !== undefined) return record.exports
       if (this.factories.has(id)) return this.materialize(id).exports
       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)',
+        `client-modules: require("${spec}") missed the module table — not a platform seed word, not a bootstrap module, `
+        + 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
       )
     }
   }
@@ -168,7 +171,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
       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, `
+          `client-modules: cannot resolve "${specifier}" — not a seed word, not a bootstrap module, `
           + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
         )
       }
@@ -178,7 +181,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   registerStatic(id: string, module: unknown): void {
-    if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
+    if (this.statics.has(id)) throw new Error(`client-modules: bootstrap module "${id}" registered twice`)
     this.statics.set(id, module)
   }
 

+ 127 - 28
packages/client/modules/src/index.ts

@@ -2,10 +2,11 @@
  * Node half of the client module system (`dsh.client` dual-face package): scans
  * the host Loader's entries for packages declaring `dsh.client`, composes the
  * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
- * in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
- * map, taps the index render to inject the boot manifest, and provides the
- * `clientModuleHost` service (the HMR node half's registration/notification
- * face).
+ * in `./client/manifest.ts`) in module-graph order, serves
+ * `/plugins/<id>/client.js` and its source map, taps the index render to
+ * inject the boot manifest plus the parser-blocking bootstrap preloads, and
+ * provides the `clientModuleHost` service (the HMR node half's
+ * registration/notification face).
  *
  * Scanning is incremental per package — there is no full-rescan code path.
  * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -30,8 +31,10 @@ import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
 import type {} from '@deepseek-ai/dsh-host-webserver'
+import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
 import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
+export { stripClientSuffix } from './client/manifest.ts'
 export type {
   BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
 } from './client/manifest.ts'
@@ -49,15 +52,29 @@ interface DshClientDeclaration {
   platform: string
   /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
   immediately?: boolean
+  /**
+   * Exact module-table requests beyond the implicit client baseline. Any
+   * specifier is valid, including subpaths such as `<pkg>/client`; each
+   * importing package declares its own exceptional requests. A type-only
+   * import is not a request because the transform erases it before resolution.
+   * Absent means the package uses only the baseline externals.
+   */
+  external?: string[]
 }
 
-/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
-interface PkgMeta {
-  clientPath: string
+/** The declared fields a graph row carries, normalized (absent array declarations become empty). */
+interface WebBootRowFields {
   inject?: string[]
+  /** Module specifiers the package requests from the module table. */
+  external: string[]
   immediately: boolean
 }
 
+/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
+interface PkgMeta extends WebBootRowFields {
+  clientPath: string
+}
+
 /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
 const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
 
@@ -99,10 +116,10 @@ class ClientPackageCompositionError extends AggregateError {
   }
 }
 
-/** One composed table row: the wire entry plus its bundle path. */
+/** One composed table row: the wire entry plus the resolved package metadata behind it. */
 interface WebPluginRecord {
   entry: WebBootEntry
-  clientPath: string
+  meta: PkgMeta
 }
 
 /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
@@ -115,15 +132,15 @@ function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration |
   if (typeof decl.platform !== 'string') {
     throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
   }
-  if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
-    throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
-  }
+  const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
+  const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
   if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
     throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
   }
   return {
     platform: decl.platform,
-    ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
+    ...(inject !== undefined ? { inject } : {}),
+    ...(external !== undefined ? { external } : {}),
     ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
   }
 }
@@ -147,27 +164,98 @@ function shortHash(input: string | Buffer): string {
 }
 
 /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
-function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
+function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
   return {
     id,
     url: `/plugins/${id}/client.js?rev=${rev}`,
     rev,
-    ...(injectEdges !== undefined ? { inject: injectEdges } : {}),
-    ...(immediately ? { immediately: true } : {}),
+    ...(fields.inject !== undefined ? { inject: fields.inject } : {}),
+    ...(fields.immediately ? { immediately: true } : {}),
+    ...(fields.external.length > 0 ? { external: fields.external } : {}),
   }
 }
 
 /**
- * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
- * first script in <head> (before the shell bundle reads it). `<` is escaped in
- * the JSON so plugin-controlled strings cannot break out of the script element.
+ * Order composed rows so every requested dynamic package precedes its
+ * consumers. An `external` specifier is either the package row it names
+ * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
+ * graph edge.
+ * @param entries - composed rows in scan order.
+ * @returns the same rows reordered; scan order breaks every tie.
+ * @throws {Error} when a row requests itself or when the module graph has a
+ * cycle; the message lists the packages on it.
+ */
+export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
+  const rowsById = new Map<string, WebBootEntry>()
+  for (const entry of entries) rowsById.set(entry.id, entry)
+  const ordered: WebBootEntry[] = []
+  const placed = new Set<string>()
+  const open: string[] = []
+  const visit = (entry: WebBootEntry): void => {
+    if (placed.has(entry.id)) return
+    const cycleStart = open.indexOf(entry.id)
+    if (cycleStart !== -1) {
+      throw new Error(
+        `client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
+        + '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
+      )
+    }
+    open.push(entry.id)
+    for (const name of entry.external ?? []) {
+      const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
+      if (dependency === entry) {
+        throw new Error(
+          `client-modules: "${entry.id}" requests module "${name}" that it answers itself `
+          + '— a row must not declare its own package in dsh.client.external',
+        )
+      }
+      if (dependency !== undefined) visit(dependency)
+    }
+    open.pop()
+    placed.add(entry.id)
+    ordered.push(entry)
+  }
+  for (const entry of entries) visit(entry)
+  return ordered
+}
+
+/** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
+const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
+
+/** Dynamic package whose ordinary client bundle must be registered before plugin boot starts. */
+const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
+
+/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
+const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
+
+/** Escape a graph URL before placing it in a quoted HTML attribute. */
+function escapeHtmlAttribute(value: string): string {
+  return value
+    .replaceAll('&', '&amp;')
+    .replaceAll('"', '&quot;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+}
+
+/**
+ * Inject the boot protocol into index.html. The inline handoff queue precedes
+ * blocking classic scripts for modules' and runtime's ordinary
+ * `lib/client.js` artifacts. The shell claims the modules handoff to construct
+ * the module system, which then adopts the remaining queued registrations.
+ * The graph script follows before the shell reads it. `<` is escaped in JSON
+ * so a plugin-controlled string cannot break out of the script element.
  * @param html - the index.html source.
  * @param graph - the composed entry graph.
  * @returns the html with the graph script injected.
  */
 export function injectBootManifest(html: string, graph: WebBootGraph): string {
   const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
-  const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
+  const queue = '<script>(()=>{const handoffs=[];window.__ModuleLoader__={mode:"queue",handoffs,load(handoff){handoffs.push(handoff)}}})()</script>'
+  const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
+    .filter((entry): entry is WebBootEntry => entry !== undefined)
+    .map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
+    .join('')
+  const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
   const head = html.indexOf('<head>')
   if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
   // Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
@@ -262,7 +350,7 @@ export class ClientModuleRegistry extends Service {
    * @returns the path, or undefined for an unknown id.
    */
   clientPath(id: string): string | undefined {
-    return this.table.get(id)?.clientPath
+    return this.table.get(id)?.meta.clientPath
   }
 
   /**
@@ -274,9 +362,9 @@ export class ClientModuleRegistry extends Service {
   rebuilt(id: string): string | undefined {
     const record = this.table.get(id)
     if (record === undefined) return undefined
-    const rev = shortHash(readFileSync(record.clientPath))
+    const rev = shortHash(readFileSync(record.meta.clientPath))
     if (rev === record.entry.rev) return rev
-    record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
+    record.entry = graphRow(id, rev, record.meta)
     this.composed = this.compose()
     for (const notify of this.rebuildListeners) {
       // Containment: rebuilt() runs inside the HMR watch callback — a
@@ -313,7 +401,7 @@ export class ClientModuleRegistry extends Service {
   }
 
   private compose(): WebBootGraph {
-    const entries = [...this.table.values()].map(record => record.entry)
+    const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
     return { rev: shortHash(JSON.stringify(entries)), entries }
   }
 
@@ -358,6 +446,7 @@ export class ClientModuleRegistry extends Service {
     const meta: PkgMeta = {
       clientPath: join(dirname(pkgPath), clientRel),
       ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
+      external: decl.external ?? [],
       immediately: decl.immediately === true,
     }
     this.pkgMeta.set(pkgName, meta)
@@ -396,7 +485,7 @@ export class ClientModuleRegistry extends Service {
     // The rev rides the row from here on: a fiber restart reuses the row (and
     // its rev) untouched; only rebuilt() re-reads the bundle.
     const rev = this.initialBundleRevision(entryName, meta.clientPath)
-    this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
+    this.table.set(entryName, { entry: graphRow(entryName, rev, meta), meta })
     return true
   }
 
@@ -412,10 +501,20 @@ export class ClientModuleRegistry extends Service {
         onError(error instanceof Error ? error : new Error(String(error)))
       }
     }
-    if (changed) {
-      this.composed = this.compose()
-      this.notifyGraphChanged()
+    if (!changed) return
+    let composed: WebBootGraph
+    try {
+      composed = this.compose()
+    } catch (error) {
+      // An unorderable module graph (cycle, or one specifier claimed by two
+      // providers) is a property of the whole table, not of the arriving
+      // package, so it surfaces here: aggregated into the activation throw, or
+      // warned in steady state while the last orderable graph stays served.
+      onError(error as Error)
+      return
     }
+    this.composed = composed
+    this.notifyGraphChanged()
   }
 
   private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {

+ 4 - 1
packages/client/modules/tsdown.config.ts

@@ -1,3 +1,6 @@
 import { clientBundle } from '../tsdown.client.ts'
 
-export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])
+export default clientBundle(
+  '@deepseek-ai/dsh-client-modules',
+  ['lib/types/index.js', 'lib/types/invariant.js'],
+)

+ 24 - 13
packages/client/runtime/package.json

@@ -42,28 +42,26 @@
   },
   "license": "MIT",
   "dependencies": {
+    "immer": "^10.1.1",
+    "zustand": "~4.4.7"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
-    "immer": "^10.1.1",
-    "react": "^18.2.0",
-    "zustand": "~4.4.7"
-  },
-  "peerDependencies": {
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-api-remotes": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/dsh-typert-registry": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -72,7 +70,20 @@
     "@deepseek-ai/dsh-timeout": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
-    "@types/react": "~18.3.1"
+    "@types/react": "~18.3.1",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-retry": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 300 - 42
packages/client/tsdown.client.ts

@@ -9,12 +9,14 @@
  * loaders register each real stylesheet as a watch dependency.
  */
 import { readFile } from 'node:fs/promises'
-import { existsSync } from 'node:fs'
-import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
+import { existsSync, globSync, readFileSync } from 'node:fs'
+import { isBuiltin } from 'node:module'
+import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { UserConfig } from 'tsdown'
 import { transform } from 'lightningcss'
-import { PLATFORM_MODULES } from './web/src/platform.ts'
+import { optionalStringArray } from './modules/src/client/manifest.ts'
+import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
 
 /**
  * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -60,8 +62,8 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
  * would read them as plugin packages. They carry no cross-plugin runtime
- * identity to share — the framework itself is a platform module (external),
- * while these are ordinary libraries a browser bundle inlines.
+ * identity to share — the framework itself is a requested module-table row
+ * (external), while these are ordinary libraries a browser bundle inlines.
  */
 const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
 
@@ -74,21 +76,6 @@ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
  */
 const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
 
-/**
- * Documented TEMPORARY exemption, not a platform module (hence not in
- * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
- * shallowEqual) lives in runtime pending its promotion-time rehoming, and
- * five importers (locale, ui-layout, ui-conversation ×3) ride this single
- * exemption. At runtime the lazy CJS table answers the require natively:
- * runtime is an immediately-tier row, its factory is registered before any
- * dependent bundle materializes. TODO(webload/store-rehome): remove with the
- * store-engine relocation follow-up.
- */
-const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
-
-/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
-export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
-
 const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
 
 /** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
@@ -123,16 +110,67 @@ export function clientBundle(
   const lib = clientLibraryConfig(id, libEntry, options.lib)
   return ({ env }) => {
     const face = buildFace(env?.DSH_BUILD_FACE)
-    const client = clientConfig(id, face === undefined
-      ? 'src/client/index.ts'
-      : 'lib/types/client/index.js')
+    const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
+    const client = clientConfig(id, clientEntry)
     const node = [lib, ...(options.companions ?? [])]
     if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
-    if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
+    if (face === 'client') {
+      return options.hostPhase === true ? [client] : [...node, client]
+    }
     return [...node, client]
   }
 }
 
+/**
+ * Build the tsdown config for a client library the compile shell links
+ * statically (the static assembly channel: `apps/web` resolves the package
+ * name, bundles the artifact, and owns the chunk layout and the CSS pipeline).
+ *
+ * Calling this preset is what puts a package in the static assembly channel,
+ * so the call sites are the roster: gates read it through
+ * {@link isStaticLinkedConfig} rather than a second hand-kept list. A package on
+ * this roster must not be a module-table row as well — the browser would take
+ * the statically linked copy and a provider's bytes would sit unused in its
+ * bundle.
+ *
+ * Four artifact contracts:
+ * 1. every bare specifier stays an import. The shell attributes chunk bytes by
+ *    `node_modules/<pkg>`, so a dependency inlined into a workspace file is
+ *    attributed to no npm package and its bytes fall into the index chunk,
+ *    which collapses the vendor/index cache split.
+ * 2. `esm` on `platform: 'browser'` — the shell is the only consumer.
+ * 3. sourcemaps, chained through the tsc maps under `lib/types` to the sources.
+ * 4. stylesheets ship with the package: a relative `.css` import survives as a
+ *    relative external and the sheet is emitted under `lib/` at its
+ *    `src`-relative path, so vite stays the only owner of class hashing.
+ * @param id - package name, used in tsdown diagnostics.
+ * @param libEntry - emitted JavaScript entries consumed from `lib/types`, one
+ * bundle each: a multi-entry build would emit a hash-named shared chunk that
+ * the exact `files` list cannot publish.
+ * @returns ENV-selected tsdown config for the Client build face.
+ */
+export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig {
+  // Each entry names its own output file, so two entries with the same basename
+  // would overwrite one artifact instead of emitting two.
+  const names = new Set(libEntry.map(entry => basename(entry, '.js')))
+  if (names.size !== libEntry.length) {
+    throw new Error(`tsdown: ${id} entries collide on an output name: ${libEntry.join(', ')}`)
+  }
+  return clientOnly(libEntry.map(entry => staticLinkedConfig(id, entry)))
+}
+
+/**
+ * Whether a package's tsdown configs put it in the static assembly channel.
+ * The roster has no separate list: gates load each package's own
+ * `tsdown.config.ts`, call it for the Client face, and ask this.
+ * @param configs - configs a package's build-face function returned.
+ * @returns true when at least one config was built by {@link staticLinked}.
+ */
+export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean {
+  return configs.some(config => (config.plugins as readonly { name?: string }[] | undefined ?? [])
+    .some(plugin => plugin.name === STATIC_LINKED_PLUGIN))
+}
+
 /**
  * Build a Client-only Node library during the Client pass.
  * @param id - Package name used in tsdown diagnostics.
@@ -178,6 +216,8 @@ function clientLibraryConfig(
   libEntry: readonly string[],
   overrides: UserConfig = {},
 ): UserConfig {
+  const isProductionDependency = (specifier: string): boolean =>
+    matchesSpecifier(productionExternals(id), specifier)
   return {
     name: id,
     entry: [...libEntry],
@@ -188,11 +228,213 @@ function clientLibraryConfig(
     fixedExtension: false,
     dts: false,
     clean: false,
+    deps: {
+      // The Node half runs from a real install: a production dependency is on
+      // disk there and stays an import, everything else inlines. Stating both
+      // halves takes the artifact off tsdown's getProductionDeps fallback, where
+      // moving a dependency between npm sections silently re-bundles it.
+      // Builtins keep tsdown's own handling (neither side claims them).
+      neverBundle: isProductionDependency,
+      alwaysBundle: (specifier: string) => !isBuiltin(specifier) && !isProductionDependency(specifier),
+    },
     ...overrides,
   }
 }
 
+/** The slice of the rolldown plugin context the stylesheet plugin uses. */
+interface AssetEmitter {
+  emitFile(file: {
+    type: 'asset'
+    fileName: string
+    source: Uint8Array
+    originalFileName: string
+  }): string
+}
+
+function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
+  const emitted = new Set<string>()
+  return {
+    name: id,
+    entry: { [outputName]: entry },
+    outDir: 'lib',
+    format: ['esm'],
+    platform: 'browser',
+    target: 'es2024',
+    fixedExtension: false,
+    dts: false,
+    clean: false,
+    // The shell compiles this artifact, so its map is the only path from a
+    // browser stack frame back to the TSX (tsc emits the lib/types half).
+    sourcemap: true,
+    plugins: [{
+      // Contract 1. `pre` because tsdown's own deps plugin would otherwise
+      // resolve and inline every specifier missing from the npm production
+      // sections, which is the coupling this preset exists to remove. The name
+      // is also the roster marker {@link isStaticLinkedConfig} reads.
+      name: STATIC_LINKED_PLUGIN,
+      resolveId: {
+        order: 'pre' as const,
+        handler(source: string, importer: string | undefined) {
+          // An entry arrives without an importer and must stay internal.
+          if (importer === undefined) return null
+          return isBareSpecifier(source) ? { id: source, external: true } : null
+        },
+      },
+    }, {
+      // Contract 3. Rolldown does not read the `//# sourceMappingURL` of its
+      // inputs, so each tsc map is handed over as that module's map and
+      // composed into the bundle map; without it frames stop at the emitted
+      // lib/types JavaScript instead of reaching the TSX.
+      name: 'dsh-tsc-sourcemap',
+      async load(id: string) {
+        if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
+        const code = await readFile(id, 'utf8')
+        return { code: code.replace(SOURCEMAP_COMMENT, ''), map: await readFile(`${id}.map`, 'utf8') }
+      },
+    }, {
+      // Contract 4. The import survives verbatim and the sheet lands beside the
+      // JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
+      name: 'dsh-css-asset',
+      async resolveId(this: AssetEmitter, source: string, importer: string | undefined) {
+        if (!source.endsWith('.css') || importer === undefined) return null
+        const { file, fileName } = stylesheetAsset(source, importer)
+        if (!emitted.has(fileName)) {
+          emitted.add(fileName)
+          // originalFileName also puts the physical sheet in the watch graph.
+          this.emitFile({ type: 'asset', fileName, source: await readFile(file), originalFileName: file })
+        }
+        // Every emitted chunk sits at the lib/ root, so the src-relative name
+        // is what resolves from there. Rolldown keeps relative externals as
+        // written instead of re-normalizing them.
+        return { id: `./${fileName}`, external: true }
+      },
+    }],
+  }
+}
+
+/** Whether a specifier names a package rather than a file next to its importer. */
+function isBareSpecifier(specifier: string): boolean {
+  return !specifier.startsWith('.') && !specifier.startsWith('\0') && !isAbsolute(specifier)
+}
+
+/**
+ * Locate a stylesheet import against the package sources and name its emitted position.
+ * @param source - relative import specifier as written in the source.
+ * @param importer - absolute path of the importing module, emitted or source.
+ * @returns the stylesheet on disk plus its `src`-relative name under `lib/`.
+ */
+function stylesheetAsset(source: string, importer: string): { readonly file: string, readonly fileName: string } {
+  const file = sourceAssetPath(source, importer)
+  const boundary = file.lastIndexOf(SOURCE_MARKER)
+  if (boundary < 0) throw new Error(`tsdown: stylesheet ${file} is outside the package sources`)
+  return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') }
+}
+
+/** The manifest fields the build faces read to state their own module edges. */
+interface WorkspaceManifest {
+  readonly name?: string
+  /** Sections a real install materializes on disk next to the built package. */
+  readonly dependencies?: Record<string, string>
+  readonly peerDependencies?: Record<string, string>
+  readonly optionalDependencies?: Record<string, string>
+  readonly dsh?: { readonly client?: { readonly external?: unknown } }
+}
+
+const manifestCache = new Map<string, WorkspaceManifest>()
+const productionExternalCache = new Map<string, readonly RegExp[]>()
+const clientExternalCache = new Map<string, ReadonlySet<string>>()
+
+/**
+ * Read one workspace package's manifest. Located by package name rather than by
+ * cwd, because tsdown evaluates every package config with the repository root as
+ * `process.cwd()` during a workspace build. Callers read it on the first
+ * resolveId of a build, not while a config is built, so selecting a build face
+ * never touches a manifest.
+ * @param id - package name, as spelled at the preset call site.
+ * @returns the parsed manifest.
+ * @throws {Error} when no workspace package declares that name.
+ */
+function workspaceManifest(id: string): WorkspaceManifest {
+  const cached = manifestCache.get(id)
+  if (cached !== undefined) return cached
+  for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {
+    const manifest = JSON.parse(
+      readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'),
+    ) as WorkspaceManifest
+    if (manifest.name !== id) continue
+    manifestCache.set(id, manifest)
+    return manifest
+  }
+  throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`)
+}
+
+/**
+ * External patterns for one package's Node half: its own production sections,
+ * subpaths included.
+ * @param id - package name, as spelled at the preset call site.
+ * @returns one `^name(/|$)` pattern per production dependency, name-sorted.
+ */
+function productionExternals(id: string): readonly RegExp[] {
+  const cached = productionExternalCache.get(id)
+  if (cached !== undefined) return cached
+  const manifest = workspaceManifest(id)
+  const names = new Set([
+    ...Object.keys(manifest.dependencies ?? {}),
+    ...Object.keys(manifest.peerDependencies ?? {}),
+    ...Object.keys(manifest.optionalDependencies ?? {}),
+  ])
+  const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`))
+  productionExternalCache.set(id, patterns)
+  return patterns
+}
+
+/**
+ * Module-table specifiers one `dsh.client` declaration requests. Matching is
+ * exact, never normalized: a package declares the specifier its own code
+ * imports, and the loader keys static entries the same way.
+ * @param subject - package name, used in diagnostics.
+ * @param declaration - the package's `dsh.client` object.
+ * @returns the requested specifiers, empty when the package declares none.
+ * @throws {Error} when `external` is not a string array.
+ */
+export function requestedExternals(
+  subject: string,
+  declaration: { readonly external?: unknown },
+): ReadonlySet<string> {
+  return new Set(optionalStringArray(subject, 'dsh.client.external', declaration.external) ?? [])
+}
+
+/**
+ * Module-table specifiers one package requests. The shell baseline is implicit
+ * for every dynamic bundle; `dsh.client.external` only adds package-specific
+ * dynamic rows or subpaths.
+ * @param id - package name, as spelled at the preset call site.
+ * @returns the baseline plus the package's explicit requests.
+ */
+function clientExternals(id: string): ReadonlySet<string> {
+  const cached = clientExternalCache.get(id)
+  if (cached !== undefined) return cached
+  const externals = new Set([
+    ...PLATFORM_MODULES,
+    ...PRELOADED_CLIENT_EXTERNALS,
+    ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}),
+  ])
+  clientExternalCache.set(id, externals)
+  return externals
+}
+
+/** Escape a package name for literal use inside a RegExp source. */
+function escapeSpecifier(name: string): string {
+  return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/** Whether an import specifier is the package a pattern names, or one of its subpaths. */
+function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boolean {
+  return patterns.some(pattern => pattern.test(specifier))
+}
+
 function clientConfig(id: string, entry: string): UserConfig {
+  const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
   return {
     name: `${id}/client`,
     entry: { client: entry },
@@ -208,7 +450,15 @@ function clientConfig(id: string, entry: string): UserConfig {
     // must carry the TS/TSX mapping consumed by browser profiling tools.
     sourcemap: true,
     clean: false,
-    external: [...CLIENT_EXTERNALS],
+    deps: {
+      neverBundle: isRequested,
+      // Anything NOT requested from the loader module table must inline
+      // (wire/type layers, zod, clsx — every non-shared dep). A require() the
+      // table cannot answer is a guaranteed runtime throw, so the rule is the
+      // package's own request list: requested specifiers stay imports,
+      // everything else is bundled.
+      alwaysBundle: (specifier: string) => !isRequested(specifier),
+    },
     // Browser bundles inline node-idiom deps (zustand/immer read
     // process.env.NODE_ENV; zustand's esm build also probes
     // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
@@ -224,28 +474,23 @@ function clientConfig(id: string, entry: string): UserConfig {
       'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
       'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
     },
-    // tsdown auto-externalizes package dependencies; anything NOT in the
-    // loader module table must inline instead (wire/type layers, zod, clsx —
-    // every non-shared dep). A require() the table cannot answer is a
-    // guaranteed runtime throw, so the rule is the table list itself: no
-    // opinion for table entries (external above wins), bundle everything else.
-    noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
     plugins: [{
-      // Bundle purity gate (build-time mirror of the module-edge rules):
-      // platform seed entries stay external, inline-safe wire layers inline,
-      // and every other @deepseek-ai value import is a build error — a
+      // Bundle purity gate (build-time mirror of the module-edge rules): the
+      // baseline and package-specific requests stay external, inline-safe wire layers
+      // inline, and every other @deepseek-ai value import is a build error — a
       // cross-plugin value import either inlines a duplicate runtime instance
-      // or requires a specifier the frozen module table cannot answer.
+      // or requires a specifier the module table cannot answer for this package.
       // Cross-plugin collaboration goes through cordis services instead.
       name: 'dsh-client-bundle-purity',
       resolveId(source: string) {
         if (!source.startsWith('@deepseek-ai/')) return null
-        if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
+        if (isRequested(source)) return null // requested module-table row: external wins
         if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
         if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
         throw new Error(
-          `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
-          + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
+          `client bundle purity: "${source}" is not in the default client externals or ${id}'s dsh.client.external, an inline-safe wire layer, or a generated /remote contribution — `
+          + 'cross-plugin value imports are forbidden; declare a non-default module request or collaborate through cordis services '
+          + '(type-only imports are erased and never reach this gate)',
         )
       },
     }, {
@@ -268,7 +513,9 @@ function clientConfig(id: string, entry: string): UserConfig {
           minify: true,
         })
         const classMap: Record<string, string> = {}
-        for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
+        const exportEntries = Object.entries(cssExports ?? {})
+          .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
+        for (const [local, exp] of exportEntries) classMap[local] = exp.name
         return styleInjectionModule(id, fileId, code.toString(), classMap)
       },
     }, {
@@ -317,12 +564,23 @@ function clientConfig(id: string, entry: string): UserConfig {
   }
 }
 
+/** Path segment separating a package's tsc output from the sources it was emitted from. */
+const TYPES_MARKER = `${sep}lib${sep}types${sep}`
+
+/** Plugin name carrying contract 1, and the marker that identifies a statically linked config. */
+const STATIC_LINKED_PLUGIN = 'dsh-static-linked-external'
+
+/** Path segment a package's sources hang under, and the root emitted assets mirror. */
+const SOURCE_MARKER = `${sep}src${sep}`
+
+/** Trailing sourcemap reference tsc appends to every emitted module. */
+const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
+
 /** Resolve an emitted JS asset import against its source-tree counterpart. */
 function sourceAssetPath(source: string, importer: string): string {
   const emitted = resolvePath(dirname(importer), source)
   if (existsSync(emitted)) return emitted
-  const marker = `${sep}lib${sep}types${sep}`
-  const boundary = emitted.indexOf(marker)
+  const boundary = emitted.indexOf(TYPES_MARKER)
   if (boundary < 0) return emitted
-  return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
+  return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + TYPES_MARKER.length))
 }

+ 1 - 4
packages/client/ui-agent-preset/package.json

@@ -54,11 +54,8 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-invariants": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 1 - 4
packages/client/ui-commands/package.json

@@ -54,13 +54,10 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 3 - 5
packages/client/ui-deliverables/package.json

@@ -45,15 +45,11 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
@@ -68,7 +64,9 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@types/react": "~18.3.1",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 1 - 4
packages/client/ui-directory-picker-browse/package.json

@@ -50,12 +50,9 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",

+ 1 - 3
packages/client/ui-directory-picker-native/package.json

@@ -45,11 +45,9 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",

+ 5 - 4
packages/client/ui-goal/package.json

@@ -50,13 +50,12 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -73,7 +72,9 @@
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 1 - 4
packages/client/ui-input-trigger/package.json

@@ -49,11 +49,8 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",

+ 2 - 6
packages/client/ui-jobs/package.json

@@ -45,15 +45,10 @@
   "publishConfig": {
     "access": "public"
   },
-  "dependencies": {
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -66,7 +61,8 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 1 - 3
packages/client/ui-layout/package.json

@@ -45,11 +45,9 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",

+ 1 - 4
packages/client/ui-message-feedback/package.json

@@ -51,13 +51,10 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 6 - 8
packages/client/ui-model-selection/package.json

@@ -52,13 +52,9 @@
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "clsx": "^2.1.1",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -68,12 +64,11 @@
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "clsx": "^2.1.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0"
   },
@@ -82,5 +77,8 @@
     "lib/invariant.js",
     "lib/client.js",
     "lib/types/**/*.d.ts"
-  ]
+  ],
+  "dependencies": {
+    "clsx": "^2.1.1"
+  }
 }

+ 1 - 4
packages/client/ui-plan/package.json

@@ -49,12 +49,9 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: f96e931472a0946bd023b97b034643f079a67e44
-README.zh.md: bf47fa2bca6f685fc52ca6ff786439a8d51230fc
+README.md: 9a5b33e6b2ecae0652d640d2a6927e3f1d05b1a1
+README.zh.md: a94935235790504bbeb7f5091e8c8fba86e1c903

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
packages/client/ui-primitives/README.md


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
packages/client/ui-primitives/README.zh.md


+ 1 - 0
packages/client/ui-primitives/package.json

@@ -57,6 +57,7 @@
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/**/*.css",
     "lib/types/**/*.d.ts"
   ],
   "peerDependencies": {

+ 5 - 30
packages/client/ui-primitives/tsdown.config.ts

@@ -1,31 +1,6 @@
-import { clientOnly } from '../tsdown.client.ts'
+import { staticLinked } from '../tsdown.client.ts'
 
-/**
- * ui-primitives is browser-only, but its lib bundle IS imported under plain
- * Node because the web shell is a lib (dsh-client-web's lib chain reaches
- * this package). CSS imports are therefore stubbed to empty modules instead
- * of externalized — the hashed class maps only matter in bundler contexts
- * (loader module table / vite source paths), which compile src directly and
- * never read lib.
- */
-export default clientOnly([{
-  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'neutral',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-  plugins: [{
-    name: 'dsh-css-stub',
-    resolveId(source: string) {
-      if (!source.endsWith('.css')) return null
-      return `\0dsh-css-stub:${source}.mjs`
-    },
-    load(id: string) {
-      if (!id.startsWith('\0dsh-css-stub:')) return null
-      return 'export default {};'
-    },
-  }],
-}])
+export default staticLinked(
+  '@deepseek-ai/dsh-client-ui-primitives',
+  ['lib/types/index.js', 'lib/types/invariant.js'],
+)

+ 3 - 5
packages/client/ui-settings-general/package.json

@@ -48,7 +48,6 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "clsx": "^2.0.0"
   },
@@ -57,13 +56,11 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-settings": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -78,7 +75,8 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-settings": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 1 - 4
packages/client/ui-settings-plugin-inventory/package.json

@@ -49,12 +49,9 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 1 - 4
packages/client/ui-settings-plugins/package.json

@@ -52,11 +52,8 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-invariants": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 2 - 1
packages/client/ui-settings/package.json

@@ -64,7 +64,8 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-client-connection": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 1 - 3
packages/client/ui-sidebar/package.json

@@ -50,11 +50,9 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",

+ 1 - 4
packages/client/ui-skill/package.json

@@ -51,13 +51,10 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 2 - 2
packages/client/ui-slots/tsdown.config.ts

@@ -1,6 +1,6 @@
-import { clientLibrary } from '../tsdown.client.ts'
+import { staticLinked } from '../tsdown.client.ts'
 
-export default clientLibrary(
+export default staticLinked(
   '@deepseek-ai/dsh-client-ui-slots',
   ['lib/types/index.js', 'lib/types/invariant.js'],
 )

+ 2 - 6
packages/client/ui-subagent/package.json

@@ -46,16 +46,11 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
@@ -73,7 +68,8 @@
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@types/react": "~18.3.1",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 4 - 5
packages/client/ui-theme/package.json

@@ -49,12 +49,10 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-settings": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -68,7 +66,9 @@
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^"
   },
   "files": [
     "lib/index.js",
@@ -82,7 +82,6 @@
     "watch": "tsdown --watch"
   },
   "dependencies": {
-    "@deepseek-ai/dsh-settings": "workspace:^",
     "clsx": "^2.0.0",
     "@deepseek-ai/schemastery": "workspace:^"
   }

+ 1 - 4
packages/client/ui-tool/package.json

@@ -53,10 +53,7 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-invariants": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 1 - 3
packages/client/ui-trajectory/package.json

@@ -53,12 +53,10 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",

+ 11 - 9
packages/client/ui-user-questions/package.json

@@ -44,19 +44,15 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/dsh-client-connection": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "clsx": "^2.0.0",
-    "react": "^18.2.0"
+    "clsx": "^2.0.0"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -67,7 +63,13 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^",
-    "@types/react": "~18.3.1"
+    "@types/react": "~18.3.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 2 - 6
packages/client/ui-workflow-run/package.json

@@ -50,15 +50,10 @@
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",
-  "dependencies": {
-    "react": "^18.2.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
@@ -77,6 +72,7 @@
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-workflow": "workspace:^",
     "@types/react": "~18.3.1",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0"
   }
 }

+ 2 - 3
packages/client/ui-workspace/package.json

@@ -51,11 +51,10 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",

+ 6 - 8
packages/client/web/package.json

@@ -26,30 +26,28 @@
     "./package.json": "./package.json"
   },
   "license": "MIT",
-  "dependencies": {
-    "@deepseek-ai/dsh-client-modules": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
     "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
     "typescript": "^6.0.3"
   },
   "peerDependencies": {
-    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/**/*.css",
     "lib/types/**/*.d.ts"
   ]
 }

+ 1 - 1
packages/client/web/src/index.ts

@@ -8,4 +8,4 @@
 
 export { AppWebEntry, type BootSeams } from './boot.ts'
 export { getStaticModules } from './seed.ts'
-export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
+export { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS, type PlatformModule } from './platform.ts'

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

@@ -11,5 +11,10 @@ export const PLATFORM_MODULES = [
   '@deepseek-ai/dsh-client-ui-primitives',
 ] as const
 
+/** Client-bundle specifiers whose factories the parser preloads before the shell starts. */
+export const PRELOADED_CLIENT_EXTERNALS = [
+  '@deepseek-ai/dsh-client-runtime/client',
+] as const
+
 /** One platform module specifier (a seed-table key). */
 export type PlatformModule = (typeof PLATFORM_MODULES)[number]

+ 5 - 30
packages/client/web/tsdown.config.ts

@@ -1,31 +1,6 @@
-import { clientOnly } from '../tsdown.client.ts'
+import { staticLinked } from '../tsdown.client.ts'
 
-/**
- * Root-shape lib build plus a CSS stub: the boot page imports
- * .module.css/.css assets that tsc passes through untouched, so the JS under
- * lib/types references css files that do not exist there. The browser
- * consumer (apps/web) compiles src directly through vite where css is real;
- * this node lib build stubs every css import to an empty module — importing
- * the lib under plain node must not crash on an asset specifier.
- */
-export default clientOnly([{
-  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'neutral',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-  plugins: [{
-    name: 'dsh-css-stub',
-    resolveId(source: string) {
-      if (!source.endsWith('.css')) return null
-      return `\0dsh-css-stub:${source}.mjs`
-    },
-    load(id: string) {
-      if (!id.startsWith('\0dsh-css-stub:')) return null
-      return 'export default {};'
-    },
-  }],
-}])
+export default staticLinked(
+  '@deepseek-ai/dsh-client-web',
+  ['lib/types/index.js', 'lib/types/invariant.js'],
+)

+ 1 - 3
packages/extensions/cordis-client-runner/package.json

@@ -51,11 +51,9 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",

+ 1 - 4
packages/extensions/ui-cordis/package.json

@@ -55,14 +55,11 @@
     "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",

+ 1 - 4
packages/session-query/session-log-export/package.json

@@ -27,11 +27,8 @@
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "react": "^18.2.0"
+    "@deepseek-ai/dsh-invariants": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 122 - 118
pnpm-lock.yaml

@@ -354,16 +354,6 @@ importers:
         version: 10.0.0
 
   apps/web:
-    dependencies:
-      '@deepseek-ai/dsh-client-web':
-        specifier: workspace:^
-        version: link:../../packages/client/web
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-      react-dom:
-        specifier: ^18.2.0
-        version: 18.3.1(react@18.3.1)
     devDependencies:
       '@deepseek-ai/cordis-plugin-group':
         specifier: workspace:^
@@ -377,6 +367,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../../packages/client/ui-slots
+      '@deepseek-ai/dsh-client-web':
+        specifier: workspace:^
+        version: link:../../packages/client/web
       '@deepseek-ai/dsh-cmdline':
         specifier: workspace:^
         version: link:../../packages/boot/cmdline
@@ -401,6 +394,12 @@ importers:
       playwright:
         specifier: ^1.49.0
         version: 1.61.1
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
       typescript:
         specifier: ^6.0.3
         version: 6.0.3
@@ -1460,6 +1459,16 @@ importers:
 
   packages/client/connection:
     dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      ws:
+        specifier: ^8.21.0
+        version: 8.21.0
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
@@ -1469,6 +1478,12 @@ importers:
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../host/apiproxy
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -1478,22 +1493,6 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
-      '@deepseek-ai/schemastery':
-        specifier: link:../../../vendor/schemastery
-        version: link:../../../vendor/schemastery
-      ws:
-        specifier: ^8.21.0
-        version: 8.21.0
-    devDependencies:
-      '@deepseek-ai/cordis':
-        specifier: workspace:^
-        version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-host-webserver':
-        specifier: workspace:^
-        version: link:../../host/webserver
-      '@deepseek-ai/dsh-invariants':
-        specifier: workspace:^
-        version: link:../../runtime-diagnostics/invariants
       '@types/ws':
         specifier: ^8.18.1
         version: 8.18.1
@@ -1522,12 +1521,6 @@ importers:
 
   packages/client/locale:
     dependencies:
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
-      '@deepseek-ai/dsh-settings':
-        specifier: workspace:^
-        version: link:../../settings/settings
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -1538,6 +1531,9 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -1556,6 +1552,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -1580,9 +1579,22 @@ importers:
 
   packages/client/runtime:
     dependencies:
+      immer:
+        specifier: ^10.1.1
+        version: 10.2.0
+      zustand:
+        specifier: ~4.4.7
+        version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
@@ -1598,6 +1610,9 @@ importers:
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../host/apiproxy
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -1613,31 +1628,12 @@ importers:
       '@deepseek-ai/dsh-session-title':
         specifier: workspace:^
         version: link:../../session/session-title
-      '@deepseek-ai/dsh-tools':
-        specifier: workspace:^
-        version: link:../../core/tools
-      immer:
-        specifier: ^10.1.1
-        version: 10.2.0
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-      zustand:
-        specifier: ~4.4.7
-        version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
-    devDependencies:
-      '@deepseek-ai/cordis':
-        specifier: workspace:^
-        version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-api-remotes':
-        specifier: workspace:^
-        version: link:../../api/remotes
-      '@deepseek-ai/dsh-invariants':
-        specifier: workspace:^
-        version: link:../../runtime-diagnostics/invariants
       '@deepseek-ai/dsh-timeout':
         specifier: workspace:^
         version: link:../../util/timeout
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
@@ -1647,6 +1643,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-agent-preset:
     devDependencies:
@@ -1877,10 +1876,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-deliverables:
-    dependencies:
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -1900,6 +1895,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
@@ -1912,6 +1910,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-directory-picker-browse:
     dependencies:
@@ -2021,6 +2022,12 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
       '@testing-library/react':
         specifier: ^16.1.0
         version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2069,10 +2076,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-jobs:
-    dependencies:
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2101,6 +2104,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-layout:
     devDependencies:
@@ -2181,6 +2187,10 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-model-selection:
+    dependencies:
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2221,9 +2231,6 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -2427,9 +2434,6 @@ importers:
 
   packages/client/ui-settings:
     dependencies:
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -2440,6 +2444,9 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -2464,9 +2471,6 @@ importers:
 
   packages/client/ui-settings-general:
     dependencies:
-      '@deepseek-ai/dsh-settings':
-        specifier: workspace:^
-        version: link:../../settings/settings
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -2507,6 +2511,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -2736,10 +2743,6 @@ importers:
         version: 18.3.31
 
   packages/client/ui-subagent:
-    dependencies:
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2777,15 +2780,12 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-theme:
     dependencies:
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
-      '@deepseek-ai/dsh-settings':
-        specifier: workspace:^
-        version: link:../../settings/settings
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -2799,6 +2799,9 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
@@ -2823,6 +2826,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -2936,27 +2942,9 @@ importers:
 
   packages/client/ui-user-questions:
     dependencies:
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
-      '@deepseek-ai/dsh-client-runtime':
-        specifier: workspace:^
-        version: link:../runtime
-      '@deepseek-ai/dsh-client-ui-conversation':
-        specifier: workspace:^
-        version: link:../ui-conversation
-      '@deepseek-ai/dsh-client-ui-primitives':
-        specifier: workspace:^
-        version: link:../ui-primitives
-      '@deepseek-ai/dsh-client-ui-slots':
-        specifier: workspace:^
-        version: link:../ui-slots
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2967,9 +2955,24 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-conversation':
+        specifier: workspace:^
+        version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
@@ -2985,12 +2988,11 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
-
-  packages/client/ui-workflow-run:
-    dependencies:
       react:
         specifier: ^18.2.0
         version: 18.3.1
+
+  packages/client/ui-workflow-run:
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3028,6 +3030,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-workspace:
     dependencies:
@@ -3070,22 +3075,6 @@ importers:
         version: 18.3.1
 
   packages/client/web:
-    dependencies:
-      '@deepseek-ai/dsh-client-modules':
-        specifier: workspace:^
-        version: link:../modules
-      '@deepseek-ai/dsh-client-ui-primitives':
-        specifier: workspace:^
-        version: link:../ui-primitives
-      '@deepseek-ai/dsh-client-ui-slots':
-        specifier: workspace:^
-        version: link:../ui-slots
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-      react-dom:
-        specifier: ^18.2.0
-        version: 18.3.1(react@18.3.1)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3093,9 +3082,18 @@ importers:
       '@deepseek-ai/cordis-plugin-loader':
         specifier: workspace:^
         version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../modules
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
       '@deepseek-ai/dsh-client-ui-renderer':
         specifier: workspace:^
         version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
@@ -3105,6 +3103,12 @@ importers:
       '@types/react-dom':
         specifier: ~18.3.0
         version: 18.3.7(@types/react@18.3.31)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
       typescript:
         specifier: ^6.0.3
         version: 6.0.3

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

@@ -135,6 +135,13 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   '@deepseek-ai/dsh-base': ['cordis.patch.yml'],
   '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
   '@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
+  // Statically linked client libraries keep their stylesheets next to the emitted
+  // JavaScript, which imports them by relative path: the compile shell runs
+  // them through its own CSS pipeline, so the sheets are published artifacts.
+  // The glob covers whichever sheets a package emits; sourcemaps stay
+  // unpublished, as everywhere else in the repository.
+  '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
+  '@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
   // The CPython side ships as source .py files, published as-is rather than built.
   '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'],

+ 47 - 11
scripts/client-bundle-purity.spec.ts

@@ -4,7 +4,7 @@
  */
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
-import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
+import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
 
 type ResolveId = (source: string) => null | { id: string; external: boolean }
 
@@ -14,7 +14,10 @@ interface CssModulePlugin {
   load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
 }
 
-function clientConfigs(id = '@deepseek-ai/dsh-client-test') {
+/** A representative dynamic bundle using the shared client baseline. */
+const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
+
+function clientConfigs(id = REQUESTING_PACKAGE) {
   return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
     { env: { DSH_BUILD_FACE: 'client' } },
   ).filter(config => config.platform === 'browser')
@@ -36,10 +39,10 @@ function clientSourceMapPath(packagePath: string): string {
   return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
 }
 
-function purityResolveId(): ResolveId {
+function purityResolveId(id = REQUESTING_PACKAGE): ResolveId {
   // libEntry is spelled at every call site (no default) so the
   // package-invariants text check can see the invariant entry per package.
-  const configs = clientConfigs()
+  const configs = clientConfigs(id)
   const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
   const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
   if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
@@ -49,7 +52,7 @@ function purityResolveId(): ResolveId {
 function cssModulePlugin(): CssModulePlugin {
   const configs = clientConfigs()
   const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
-  const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
+  const plugin = plugins.find(candidate => candidate.name === 'dsh-css-inline')
   if (plugin?.resolveId === undefined || plugin.load === undefined) {
     throw new Error('CSS Modules plugin missing from client config')
   }
@@ -59,9 +62,10 @@ function cssModulePlugin(): CssModulePlugin {
 describe('client bundle purity gate', () => {
   const resolveId = purityResolveId()
 
-  it('leaves platform table entries and non-scoped specifiers alone', () => {
+  it('leaves default externals and non-scoped specifiers alone', () => {
     expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
     expect(resolveId('react')).toBeNull()
     expect(resolveId('zod')).toBeNull()
   })
@@ -89,17 +93,49 @@ describe('client bundle purity gate', () => {
     expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
   })
 
-  it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
+  it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
     expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
     expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
     expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
   })
 
-  it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
+  it('admits the parser-preloaded runtime for every dynamic bundle', () => {
     expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
-    const clientChannels = CLIENT_EXTERNALS.filter(
-      entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
-    expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
+    const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal')
+    expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
+  })
+
+  it('externalizes the baseline independently of each package manifest', () => {
+    const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
+    const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
+      neverBundle: (specifier: string) => boolean
+    }
+
+    expect(requesting.neverBundle('react')).toBe(true)
+    expect(requesting.neverBundle('zod')).toBe(false)
+    expect(plain.neverBundle('react')).toBe(true)
+    expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true)
+  })
+})
+
+describe('client bundle module requests', () => {
+  it('requests what the declaration lists', () => {
+    const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
+      external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
+    })
+
+    expect([...requests].sort()).toEqual([
+      '@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
+    ])
+  })
+
+  it('requests nothing when the declaration is absent', () => {
+    expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
+  })
+
+  it('rejects a malformed declaration instead of reading past it', () => {
+    expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
+      .toThrow(/dsh\.client\.external must be a string array/)
   })
 })
 

+ 26 - 1
scripts/dev-web.spec.ts

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { expect, it } from 'vitest'
 import type { TsdownBundle } from 'tsdown'
-import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
+import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
 
 it('discovers dsh.client packages with sibling roles', async () => {
   const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
@@ -24,6 +24,31 @@ it('discovers dsh.client packages with sibling roles', async () => {
   }
 })
 
+it('discovers client-preset packages the shell links, excluding loader-delivered and test infrastructure', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-library-'))
+  try {
+    const write = async (dir: string, manifest: unknown, config: string): Promise<void> => {
+      await mkdir(join(root, dir), { recursive: true })
+      await writeFile(join(root, dir, 'package.json'), JSON.stringify(manifest))
+      await writeFile(join(root, dir, 'tsdown.config.ts'), config)
+    }
+    const clientPreset = "import { clientLibrary } from '../tsdown.client.ts'\nexport default clientLibrary('x', [])\n"
+
+    // Linked by the compile shell: client preset, no loader-delivered half.
+    await write('packages/client/linked', {}, clientPreset)
+    // Loader-delivered: discoverPluginDirs owns it, so it must not appear twice.
+    await write('packages/client/delivered', { dsh: { client: { platform: 'web' } } }, clientPreset)
+    // Test infrastructure builds through the preset but never enters the shell graph.
+    await write('packages/test-support/harness', {}, clientPreset)
+    // Host package with its own config: not a client-face build at all.
+    await write('packages/host/server', {}, "import { defineConfig } from 'tsdown'\nexport default defineConfig({})\n")
+
+    expect(discoverLibraryDirs(root)).toEqual(['packages/client/linked'])
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
 it('rebuilds a client-plugin bundle after its source changes', async () => {
   const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
   let bundles: TsdownBundle[] = []

+ 137 - 13
scripts/dev-web.ts

@@ -1,17 +1,28 @@
 /**
- * Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
- * through the tsdown JS API in watch mode. Reload signaling is not this
- * script's business — the host webserver stat-polls the bundles it serves and
- * broadcasts `rebuilt` frames itself (`dsh web`), so any process that
- * rewrites `lib/client.js` files triggers reloads; this script is merely the
- * convenient way to keep them all rebuilt on source change.
+ * Watch-build for the web dev loop: rebuilds every artifact the browser reads
+ * from a source edit. Reload signaling is not this script's business — the host
+ * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
+ * itself (`dsh web`), so any process that rewrites `lib/client.js` files
+ * triggers reloads; this script is merely the convenient way to keep them all
+ * rebuilt on source change.
  *
- * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
- * packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
- * config's entries are tsc output. `--poll` switches the source-file watcher
- * to polling (default 500ms): network mounts (weka) deliver no inotify
+ * Three stages, because the compile shell links built lib products rather than
+ * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
+ * entries are that emit, not `src`), tsdown bundles `lib/index.js` and
+ * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
+ * serves. A missing stage does not fail — it silently shows the previous
+ * artifact, so an edit appears to do nothing.
+ *
+ * MUST NOT run concurrently with `pnpm run build`: both write the same
+ * `lib/` and `apps/web/dist/` trees.
+ *
+ * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
+ * `pnpm run build`: every stage is incremental over the previous stage's output
+ * and none of them bootstraps a missing tree. `--poll` switches the source
+ * watchers to polling (default 500ms): network mounts (weka) deliver no inotify
  * events, so native watching sees the initial build only and never a source
- * change.
+ * change. Polling has to reach tsc too — a native-watching tsc never re-emits
+ * `lib/types`, which strands the other two stages on stale input.
  *
  * Each package keeps its own tsdown.config.ts untouched: this script layers
  * `watch` through API-level inline config (tsdown workspace mode fills inline
@@ -20,11 +31,24 @@
 import { globSync, readFileSync } from 'node:fs'
 import { dirname, join, resolve, sep } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
 import { build } from 'tsdown'
 import type { TsdownBundle } from 'tsdown'
 
 const repoRoot = fileURLToPath(new URL('..', import.meta.url))
 
+/** Client-face type emit feeding every tsdown lib entry in the watch set. */
+const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
+
+/** Compile-shell workspace whose dist `dsh web` serves. */
+const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
+
+/**
+ * Test infrastructure builds through the client preset but never enters the
+ * shell's module graph, so it is not a dev-loop artifact.
+ */
+const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
+
 /**
  * Discover the watch workspace by declaration: every packages/<group>/<name>
  * whose package.json carries `dsh.client` with platform "web" is a client
@@ -44,6 +68,32 @@ export function discoverPluginDirs(root = repoRoot): string[] {
   return dirs
 }
 
+/**
+ * Discover the statically linked library packages: the other half of the same
+ * partition {@link discoverPluginDirs} takes. A package that builds through the
+ * client preset without declaring `dsh.client` has no loader-delivered browser
+ * half, so the compile shell links its `lib/index.js` instead — and an edit to
+ * its source reaches the browser only once that bundle is rewritten. Deriving
+ * the set from the build preset rather than a hand list keeps it correct when
+ * dependency sections move around; deriving it from `dependencies` would not,
+ * because client packages declare their build inputs as devDependencies.
+ * @param root - repository root containing the grouped package directories.
+ * @returns workspace-relative library package directories.
+ */
+export function discoverLibraryDirs(root = repoRoot): string[] {
+  const dirs: string[] = []
+  for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
+    const dir = dirname(configPath).split(sep).join('/')
+    if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
+    if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
+    const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
+      dsh?: { client?: unknown }
+    }
+    if (manifest.dsh?.client === undefined) dirs.push(dir)
+  }
+  return dirs
+}
+
 /**
  * Start the tsdown watch build used by `pnpm run dev:web`.
  * @param root - repository or fixture root passed to tsdown.
@@ -85,14 +135,56 @@ export async function watchClientPlugins(
   return bundles
 }
 
+/**
+ * Live watcher processes to terminate when this script is interrupted. Stages
+ * register themselves as they start, so the set is complete from the first
+ * spawn: an interrupt during a later stage's startup still tears down the
+ * earlier ones instead of orphaning them.
+ */
+const stages: StageHandle[] = []
+
+/**
+ * Spawn one watcher stage, inheriting stdio, registering it for teardown, and
+ * failing loud if it ever exits: a dead stage leaves the artifact chain silently
+ * stale, which reads as "my edit did nothing" — the one failure this script
+ * exists to prevent.
+ * @param stage - command label used in the exit diagnostic.
+ * @param command - executable, resolved from the workspace bin when local.
+ * @param args - command arguments.
+ * @param local - whether to resolve `command` from the workspace's installed bins.
+ */
+function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
+  const child = execa(command, [...args], {
+    cwd: repoRoot,
+    stdio: 'inherit',
+    preferLocal: local,
+    reject: false,
+  })
+  stages.push({ kill: () => { child.kill() } })
+  void child.then((result) => {
+    console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
+    process.exit(1)
+  })
+}
+
+/** The only capability this script needs from a live watcher process. */
+interface StageHandle {
+  readonly kill: () => void
+}
+
 const invokedPath = process.argv[1]
 const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
 if (isMain) {
   const pluginDirs = discoverPluginDirs()
+  const libraryDirs = discoverLibraryDirs()
   if (pluginDirs.length === 0) {
     console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
     process.exit(1)
   }
+  if (libraryDirs.length === 0) {
+    console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
+    process.exit(1)
+  }
 
   const args = process.argv.slice(2)
   const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
@@ -106,9 +198,41 @@ if (isMain) {
     process.exit(1)
   }
 
-  await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
+  // Registered before any stage starts: `stages` is read at signal time, so an
+  // interrupt during tsdown's initial builds still kills whatever is running.
+  const stop = (): void => { for (const stage of stages) stage.kill() }
+  process.once('SIGINT', stop)
+  process.once('SIGTERM', stop)
+
+  // tsc has no polling interval flag, so `--poll` selects its fixed-interval
+  // watchers rather than an interval. Dropping that translation leaves tsc
+  // natively watching on a network mount where inotify never fires: it stops
+  // re-emitting lib/types, and the two later stages then rebuild forever from
+  // stale input without printing anything.
+  spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
+    '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
+    ...pollInterval !== undefined
+      ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
+      : [],
+  ], true)
+
+  // tsdown's initial builds are awaited before the dist watcher starts so vite's
+  // first build reads current lib bundles rather than whatever the last full
+  // build left. Its own watch then covers later lib rewrites — those files are
+  // in its module graph.
+  await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
+  // Through the shell's own `watch` script rather than vite's API: vite is not a
+  // repository-root dependency, and more importantly the vite root is its
+  // working directory — `resolve.dedupe` resolves react from that root, so
+  // running vite from anywhere but apps/web silently switches which react copy
+  // the bundle gets.
+  spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
+
   console.log(
     `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
-    + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n  ${pluginDirs.join('\n  ')}`,
+    + ` and ${String(libraryDirs.length)} statically linked library packages`
+    + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
+    + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n  `
+    + [...pluginDirs, ...libraryDirs].join('\n  '),
   )
 }

+ 33 - 5
scripts/publint-all.spec.ts

@@ -13,7 +13,11 @@ afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
-function fixture(exportPath = './lib/index.js'): string {
+function fixture(options: {
+  exportPath?: string
+  indexSource?: string
+  files?: Record<string, string>
+} = {}): string {
   const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
   roots.push(root)
   const packageDir = join(root, 'packages/core/probe')
@@ -26,10 +30,14 @@ function fixture(exportPath = './lib/index.js'): string {
     engines: { node: '>=22.19' },
     sideEffects: false,
     files: ['lib'],
-    exports: { '.': { default: exportPath } },
+    exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
   }, null, 2)}\n`)
   writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
-  writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
+  writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
+  for (const [path, source] of Object.entries(options.files ?? {})) {
+    mkdirSync(join(packageDir, path, '..'), { recursive: true })
+    writeFileSync(join(packageDir, path), source)
+  }
   writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
   return root
 }
@@ -54,14 +62,34 @@ describe('publint package runner', () => {
   })
 
   it('rejects an export that exists in the workspace but is not published', () => {
-    const result = run(fixture('./unpublished.js'))
+    const result = run(fixture({ exportPath: './unpublished.js' }))
     expect(result.status).toBe(1)
     expect(result.stdout).toContain('unpublished.js')
   })
 
   it('rejects a public export whose built file is missing', () => {
-    const result = run(fixture('./lib/missing.js'))
+    const result = run(fixture({ exportPath: './lib/missing.js' }))
     expect(result.status).toBe(1)
     expect(result.stdout).toContain('missing.js')
   })
+
+  it('accepts published relative JavaScript and CSS targets', () => {
+    const result = run(fixture({
+      indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
+      files: {
+        'lib/helper.js': 'export const helper = true\n',
+        'lib/theme.css': ':root {}\n',
+      },
+    }))
+    expect(result.status, result.stderr).toBe(0)
+  })
+
+  it('rejects unpublished relative JavaScript and CSS targets', () => {
+    const result = run(fixture({
+      indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
+    }))
+    expect(result.status).toBe(1)
+    expect(result.stderr).toContain('imports "./missing.js"')
+    expect(result.stderr).toContain('imports "./missing.css"')
+  })
 })

+ 88 - 8
scripts/publint-all.ts

@@ -7,10 +7,11 @@ import {
   statSync,
 } from 'node:fs'
 import { availableParallelism } from 'node:os'
-import { dirname, relative, resolve, sep } from 'node:path'
+import { dirname, posix, relative, resolve, sep } from 'node:path'
 import { parseArgs } from 'node:util'
 import { publint, type Message, type PackFile } from 'publint'
 import { formatMessage } from 'publint/utils'
+import ts from 'typescript'
 
 const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
 const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -32,8 +33,21 @@ interface PackageManifest {
 }
 
 type PublintResult =
-  | { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
-  | { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
+  | {
+    path: string
+    status: 'passed'
+    messages: Message[]
+    closureViolations: string[]
+    manifest: Record<string, unknown>
+  }
+  | {
+    path: string
+    status: 'failed'
+    messages: Message[]
+    closureViolations: string[]
+    manifest: Record<string, unknown>
+    failure?: string
+  }
 
 function workspacePackages(): PackageTarget[] {
   return globSync('packages/*/*/package.json', { cwd: packagesRoot })
@@ -103,21 +117,84 @@ function addPath(path: string, paths: Set<string>): void {
   }
 }
 
+interface RelativeImport {
+  specifier: string
+  line: number
+}
+
+/** Return relative imports whose targets are absent from the publication view. */
+function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
+  const published = new Set(files.map(file => file.name))
+  const violations: string[] = []
+  for (const file of files) {
+    if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
+    const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
+    const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
+    for (const imported of relativeImports(file.name, source)) {
+      const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
+      if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
+      violations.push(
+        `${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
+        + ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
+        + ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
+      )
+    }
+  }
+  return violations
+}
+
+/** Paths a relative JavaScript module request can resolve to in a published package. */
+function resolutionCandidates(target: string): string[] {
+  const base = target.replace(/\/+$/, '')
+  return [
+    target,
+    ...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
+  ]
+}
+
+/** Extract relative static imports, re-exports, dynamic imports, and requires. */
+function relativeImports(file: string, sourceText: string): RelativeImport[] {
+  const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
+  const imports: RelativeImport[] = []
+  const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
+    if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
+    imports.push({
+      specifier: literal.text,
+      line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
+    })
+  }
+  const visit = (node: ts.Node): void => {
+    if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+      record(node, node.moduleSpecifier)
+    } else if (ts.isCallExpression(node)
+      && (node.expression.kind === ts.SyntaxKind.ImportKeyword
+        || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
+      record(node, node.arguments[0])
+    }
+    ts.forEachChild(node, visit)
+  }
+  visit(source)
+  return imports
+}
+
 async function runPublint(target: PackageTarget): Promise<PublintResult> {
   try {
+    const files = publicationFiles(target)
+    const closureViolations = publicationClosureViolations(target, files)
     const result = await publint({
       pkgDir: 'package',
-      pack: { files: publicationFiles(target) },
+      pack: { files },
     })
     const manifest = result.pkg as Record<string, unknown>
-    return result.messages.some(message => message.type === 'error')
-      ? { path: target.path, status: 'failed', messages: result.messages, manifest }
-      : { path: target.path, status: 'passed', messages: result.messages, manifest }
+    return result.messages.some(message => message.type === 'error') || closureViolations.length > 0
+      ? { path: target.path, status: 'failed', messages: result.messages, closureViolations, manifest }
+      : { path: target.path, status: 'passed', messages: result.messages, closureViolations, manifest }
   } catch (error: unknown) {
     return {
       path: target.path,
       status: 'failed',
       messages: [],
+      closureViolations: [],
       manifest: target.manifest as Record<string, unknown>,
       failure: error instanceof Error ? error.message : String(error),
     }
@@ -150,7 +227,10 @@ function printResult(result: PublintResult): void {
   for (const message of result.messages) {
     console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
   }
-  if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
+  for (const violation of result.closureViolations) console.error(violation)
+  if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
+    console.log('All good!')
+  }
 }
 
 const packages = workspacePackages()

+ 2 - 2
scripts/rescope-vendor.ts

@@ -275,8 +275,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
     replace: `/**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
  * would read them as plugin packages. They carry no cross-plugin runtime
- * identity to share — the framework itself is a platform module (external),
- * while these are ordinary libraries a browser bundle inlines.
+ * identity to share — the framework itself is a requested module-table row
+ * (external), while these are ordinary libraries a browser bundle inlines.
  */
 const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
 

+ 9 - 0
scripts/run-gates.spec.ts

@@ -92,6 +92,15 @@ describe('gate graph validation', () => {
     },
   )
 
+  it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
+    'keeps the client dependency policy in %s',
+    (mode) => {
+      const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
+
+      expect(ids).toContain('client-packages')
+    },
+  )
+
   it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
     const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
     const byId = new Map(gates.map(subject => [subject.id, subject]))

+ 2 - 0
scripts/run-gates.ts

@@ -252,6 +252,7 @@ function ciSharedStaticGates(): Gate[] {
     pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
       label: 'optional dependency imports',
     }),
+    pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
     pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
   ]
 }
@@ -584,6 +585,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
     pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
       label: 'optional dependency imports',
     }),
+    pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
   ]
 }
 

+ 317 - 0
scripts/verify-client-packages.spec.ts

@@ -0,0 +1,317 @@
+/** Tests for client package modes, dependency sections, and module requests. */
+
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  collectClientPackageViolations,
+  collectSourcePackageUses,
+  fixClientPackageManifests,
+  readClientDeclarations,
+  type ClientDeclaration,
+  type ClientPackage,
+  type ClientPackageFacts,
+} from './verify-client-packages.ts'
+
+const CORDIS = '@deepseek-ai/cordis'
+const roots: string[] = []
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function declaration(
+  short: string,
+  fields: Partial<Omit<ClientDeclaration, 'name' | 'manifest'>> = {},
+): ClientDeclaration {
+  return {
+    name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
+    manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
+    dynamic: true,
+    external: [],
+    inject: [],
+    ...fields,
+  }
+}
+
+function pkg(
+  short: string,
+  fields: Partial<Omit<ClientPackage, 'name' | 'manifest'>> = {},
+): ClientPackage {
+  return {
+    ...declaration(short),
+    staticLinked: false,
+    sourceUses: {},
+    dependencies: {},
+    peerDependencies: { [CORDIS]: 'workspace:^' },
+    devDependencies: { [CORDIS]: 'workspace:^' },
+    ...fields,
+  }
+}
+
+function facts(
+  packages: readonly ClientPackage[],
+  options: Partial<Omit<ClientPackageFacts, 'packages'>> = {},
+): ClientPackageFacts {
+  return {
+    packages,
+    declarations: options.declarations ?? packages,
+    staticLinkedPackages: options.staticLinkedPackages ?? new Set(
+      packages.filter(item => item.staticLinked).map(item => item.name),
+    ),
+    platformModules: options.platformModules ?? [],
+    preloadedExternals: options.preloadedExternals ?? [],
+    malformed: options.malformed ?? [],
+  }
+}
+
+describe('source package uses', () => {
+  it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
+    const uses = collectSourcePackageUses('feature.tsx', [
+      "import type { A } from '@deepseek-ai/dsh-a/subpath'",
+      "declare module '@deepseek-ai/dsh-client-ui-slots' {}",
+      "const load = () => import('@deepseek-ai/dsh-b')",
+      'export const view = <div />',
+      "export type { Local } from './local.ts'",
+    ].join('\n'))
+
+    expect([...uses].sort()).toEqual([
+      '@deepseek-ai/dsh-a',
+      '@deepseek-ai/dsh-b',
+      '@deepseek-ai/dsh-client-ui-slots',
+      'react',
+    ])
+  })
+})
+
+describe('package modes', () => {
+  it('accepts one dynamic package and one statically linked package', () => {
+    const dynamic = pkg('runtime')
+    const shell = pkg('ui-slots', { dynamic: false, staticLinked: true })
+    expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([])
+  })
+
+  it('rejects a package with both modes or neither mode', () => {
+    const both = pkg('both', { staticLinked: true })
+    const neither = pkg('neither', { dynamic: false })
+    const found = collectClientPackageViolations(facts([both, neither]))
+    expect(found).toHaveLength(2)
+    expect(found.join('\n')).toContain('must be dynamic or statically linked, not both')
+    expect(found.join('\n')).toContain('has no supported client package mode')
+  })
+
+  it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => {
+    const slots = declaration('ui-slots', { dynamic: false })
+    const runtime = declaration('runtime', { dynamic: false })
+    const found = collectClientPackageViolations(facts([], {
+      declarations: [slots, runtime],
+      platformModules: [slots.name],
+      preloadedExternals: [runtime.name + '/client'],
+    }))
+    expect(found).toHaveLength(2)
+    expect(found.join('\n')).toContain('does not use the staticLinked preset')
+    expect(found.join('\n')).toContain('has no dynamic dsh.client row')
+  })
+})
+
+describe('dependency sections', () => {
+  it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
+    const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
+    const runtime = pkg('runtime', {
+      inject: ['@deepseek-ai/dsh-client-feature'],
+      sourceUses: {
+        '@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'],
+        '@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'],
+        react: ['packages/client/runtime/src/client/view.tsx'],
+      },
+      dependencies: { immer: '^10.1.1' },
+      peerDependencies: {
+        [CORDIS]: 'workspace:^',
+        '@deepseek-ai/dsh-agent': 'workspace:^',
+        '@deepseek-ai/dsh-client-feature': 'workspace:^',
+      },
+      devDependencies: {
+        [CORDIS]: 'workspace:^',
+        '@deepseek-ai/dsh-agent': 'workspace:^',
+        '@deepseek-ai/dsh-client-feature': 'workspace:^',
+        '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
+        react: '^18.2.0',
+      },
+    })
+    expect(collectClientPackageViolations(facts([slots, runtime], {
+      platformModules: ['react', slots.name],
+    }))).toEqual([])
+  })
+
+  it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
+    const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
+    const subject = pkg('feature', {
+      sourceUses: {
+        '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
+        [slots.name]: ['packages/client/feature/src/view.tsx'],
+      },
+      dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
+      peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
+    })
+    const found = collectClientPackageViolations(facts([slots, subject]))
+    expect(found).toHaveLength(2)
+    expect(found.join('\n')).toContain('peer-installed DSH relationship')
+    expect(found.join('\n')).toContain('static client input')
+  })
+
+  it('requires every peer to have the same development range', () => {
+    const subject = pkg('feature', {
+      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
+    })
+    expect(collectClientPackageViolations(facts([subject]))).toEqual([
+      'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
+      + ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
+      + ' found no declaration',
+    ])
+  })
+
+  it('allows npm dependency cycles', () => {
+    const a = pkg('a', {
+      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
+    })
+    const b = pkg('b', {
+      peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
+      devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
+    })
+    expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
+  })
+})
+
+describe('module requests', () => {
+  it('accepts a dynamic row supplier and its client subpath', () => {
+    const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] })
+    const slots = declaration('slots')
+    expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([])
+  })
+
+  it('rejects an explicit baseline request', () => {
+    const ui = declaration('ui', { external: ['react'] })
+    expect(collectClientPackageViolations(facts([], {
+      declarations: [ui],
+      platformModules: ['react'],
+    }))).toEqual([
+      ui.manifest + ': dsh.client.external repeats baseline module "react"; remove the explicit declaration',
+    ])
+  })
+
+  it('rejects duplicates, empty values, self-requests, and missing suppliers', () => {
+    const ui = declaration('ui', {
+      external: ['', '@deepseek-ai/dsh-client-ui', '@deepseek-ai/dsh-missing', '@deepseek-ai/dsh-missing'],
+      inject: ['', '@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a'],
+    })
+    const found = collectClientPackageViolations(facts([], { declarations: [ui] }))
+    expect(found).toHaveLength(6)
+    expect(found.join('\n')).toContain('dsh.client.external contains an empty value')
+    expect(found.join('\n')).toContain('dsh.client.inject contains an empty value')
+    expect(found.join('\n')).toContain('names its own row')
+    expect(found.join('\n')).toContain('has no supplier')
+  })
+
+  it('rejects synchronous module-request cycles but ignores inject cycles', () => {
+    const a = declaration('a', {
+      external: ['@deepseek-ai/dsh-client-b'],
+      inject: ['@deepseek-ai/dsh-client-b'],
+    })
+    const b = declaration('b', {
+      external: ['@deepseek-ai/dsh-client-a'],
+      inject: ['@deepseek-ai/dsh-client-a'],
+    })
+    const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
+    expect(found).toHaveLength(1)
+    expect(found[0]).toContain('synchronous dsh.client.external cycle')
+  })
+})
+
+describe('manifest declarations', () => {
+  it('reports malformed arrays without hiding other packages', () => {
+    const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
+    roots.push(root)
+    const files: Record<string, unknown> = {
+      'packages/g/a/package.json': {
+        name: '@f/a', dsh: { client: { external: 'react', inject: ['@f/b', 1] } },
+      },
+      'packages/g/b/package.json': { name: '@f/b', dsh: { client: {} } },
+    }
+    for (const [path, value] of Object.entries(files)) {
+      mkdirSync(dirname(join(root, path)), { recursive: true })
+      writeFileSync(join(root, path), JSON.stringify(value))
+    }
+
+    const result = readClientDeclarations(root)
+    expect(result.declarations).toHaveLength(2)
+    expect(result.malformed).toEqual([
+      'packages/g/a/package.json: @f/a dsh.client.external must be a string array',
+      'packages/g/a/package.json: @f/a dsh.client.inject must be a string array',
+    ])
+  })
+
+  it('fixes unambiguous dependency sections and declaration entries', () => {
+    const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
+    roots.push(root)
+    const subject = pkg('feature', {
+      external: ['', 'react', '@deepseek-ai/dsh-client-feature', '@deepseek-ai/dsh-missing'],
+      inject: ['', '@deepseek-ai/dsh-agent', '@deepseek-ai/dsh-agent'],
+      sourceUses: {
+        '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
+        '@deepseek-ai/dsh-client-ui-slots': ['packages/client/feature/src/view.tsx'],
+      },
+      dependencies: {
+        [CORDIS]: 'workspace:^',
+        '@deepseek-ai/dsh-agent': 'workspace:*',
+      },
+      peerDependencies: {
+        '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
+        '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
+      },
+      devDependencies: {},
+    })
+    const slots = declaration('ui-slots', { dynamic: false })
+    const manifest = {
+      name: subject.name,
+      dsh: { client: { external: subject.external, inject: subject.inject, platform: 'web' } },
+      dependencies: subject.dependencies,
+      peerDependencies: subject.peerDependencies,
+      devDependencies: subject.devDependencies,
+    }
+    mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
+    writeFileSync(join(root, subject.manifest), JSON.stringify(manifest))
+    writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
+
+    expect(fixClientPackageManifests(root, facts([subject], {
+      declarations: [subject, slots],
+      staticLinkedPackages: new Set([slots.name]),
+      platformModules: ['react', slots.name],
+    }))).toEqual([subject.manifest])
+
+    const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
+      dsh: { client: { external: string[]; inject: string[] } }
+      dependencies?: Record<string, string>
+      peerDependencies: Record<string, string>
+      devDependencies: Record<string, string>
+    }
+    expect(fixed.dsh.client).toMatchObject({
+      external: ['@deepseek-ai/dsh-missing'],
+      inject: ['@deepseek-ai/dsh-agent'],
+    })
+    expect(fixed.dependencies).toBeUndefined()
+    expect(fixed.peerDependencies).toEqual({
+      '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
+      [CORDIS]: 'workspace:^',
+      '@deepseek-ai/dsh-agent': 'workspace:*',
+    })
+    expect(fixed.devDependencies).toEqual({
+      '@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
+      [CORDIS]: 'workspace:^',
+      '@deepseek-ai/dsh-agent': 'workspace:*',
+      '@deepseek-ai/cordis-plugin-loader': 'workspace:^',
+    })
+  })
+})

+ 819 - 0
scripts/verify-client-packages.ts

@@ -0,0 +1,819 @@
+/**
+ * Verify client package modes, npm dependency sections, and the synchronous
+ * browser module-request graph.
+ */
+
+import { globSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, resolve, sep } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import ts from 'typescript'
+import { TypeScriptProject } from './ts-project.ts'
+
+const GATE = 'verify-client-packages'
+const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
+const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
+const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
+const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
+const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
+const CORDIS = '@deepseek-ai/cordis'
+const DSH_PREFIX = '@deepseek-ai/dsh-'
+
+/** One workspace package's browser-module declaration. */
+export interface ClientDeclaration {
+  /** npm package name. */
+  readonly name: string
+  /** Repository-relative package manifest. */
+  readonly manifest: string
+  /** Whether the manifest declares a dynamic dsh.client row. */
+  readonly dynamic: boolean
+  /** Exact module-table specifiers requested by the row. */
+  readonly external: readonly string[]
+  /** Informational package dependencies declared by the row. */
+  readonly inject: readonly string[]
+}
+
+/** One package directly under packages/client. */
+export interface ClientPackage extends ClientDeclaration {
+  /** Whether its build config uses the staticLinked preset. */
+  readonly staticLinked: boolean
+  /** Production source locations grouped by imported package name. */
+  readonly sourceUses: Readonly<Record<string, readonly string[]>>
+  /** Installed implementation dependencies. */
+  readonly dependencies: Readonly<Record<string, string>>
+  /** Consumer-supplied dependencies. */
+  readonly peerDependencies: Readonly<Record<string, string>>
+  /** Dependencies available while developing the package. */
+  readonly devDependencies: Readonly<Record<string, string>>
+}
+
+/** Complete source-plane input to the client package verifier. */
+export interface ClientPackageFacts {
+  /** Packages directly under packages/client. */
+  readonly packages: readonly ClientPackage[]
+  /** Every workspace package, including packages without a browser row. */
+  readonly declarations: readonly ClientDeclaration[]
+  /** Packages whose build config uses the staticLinked preset. */
+  readonly staticLinkedPackages: ReadonlySet<string>
+  /** Specifiers the web shell seeds into the module table. */
+  readonly platformModules: readonly string[]
+  /** Dynamic factories the HTML parser loads before shell boot. */
+  readonly preloadedExternals: readonly string[]
+  /** Manifest field errors found while reading declarations. */
+  readonly malformed: readonly string[]
+}
+
+/** Result of reading every workspace browser-module declaration. */
+export interface ClientDeclarations {
+  /** One declaration record per named workspace manifest. */
+  readonly declarations: ClientDeclaration[]
+  /** Manifest field errors that prevent a reliable declaration. */
+  readonly malformed: string[]
+}
+
+/**
+ * Collect bare packages referenced by one production source file.
+ * @param path - File path used to select TypeScript's parser mode.
+ * @param source - Source text to inspect.
+ * @returns Bare package names referenced by imports, declarations, or JSX.
+ */
+export function collectSourcePackageUses(path: string, source: string): Set<string> {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
+  return collectSourceFilePackageUses(sourceFile)
+}
+
+function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set<string> {
+  const uses = new Set<string>()
+
+  const add = (specifier: ts.Expression | undefined): void => {
+    if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
+    uses.add(packageNameOf(specifier.text))
+  }
+  const visit = (node: ts.Node): void => {
+    if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+      add(node.moduleSpecifier)
+    } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
+      add(node.moduleReference.expression)
+    } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
+      add(node.argument.literal)
+    } else if (ts.isCallExpression(node)
+      && (node.expression.kind === ts.SyntaxKind.ImportKeyword
+        || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
+      add(node.arguments[0])
+    } else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
+      add(node.name)
+    } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
+      uses.add('react')
+    }
+    ts.forEachChild(node, visit)
+  }
+
+  visit(sourceFile)
+  return uses
+}
+
+/**
+ * Read browser-module declarations from workspace manifests.
+ * @param root - Absolute repository root.
+ * @returns Declarations and malformed dsh.client fields.
+ */
+export function readClientDeclarations(root: string): ClientDeclarations {
+  const malformed: string[] = []
+  const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
+    .map(normalizePath)
+    .sort()
+    .flatMap(path => readDeclaration(root, path, malformed) ?? [])
+  return { declarations, malformed }
+}
+
+/**
+ * Return every client package policy violation.
+ * @param facts - Package modes, manifests, source uses, and platform module lists.
+ * @returns Stable self-contained diagnostics.
+ */
+export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
+  return [
+    ...facts.malformed,
+    ...collectModeViolations(facts),
+    ...collectDependencyViolations(facts),
+    ...collectModuleViolations(facts),
+  ].sort((left, right) => left.localeCompare(right))
+}
+
+interface ManifestDocument {
+  readonly path: string
+  readonly manifest: Manifest
+  changed: boolean
+}
+
+type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
+
+/**
+ * Repair manifest declarations whose intended result follows uniquely from the policy.
+ * @param root - Absolute repository root.
+ * @param facts - Facts used by the verification pass.
+ * @returns Repository-relative manifests written by the fixer.
+ */
+export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
+  const documents = new Map<string, ManifestDocument>()
+  const document = (path: string): ManifestDocument => {
+    const cached = documents.get(path)
+    if (cached !== undefined) return cached
+    const loaded: ManifestDocument = {
+      path,
+      manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
+      changed: false,
+    }
+    documents.set(path, loaded)
+    return loaded
+  }
+
+  const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
+  for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
+    const target = document(declaration.manifest)
+    const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
+    const client = isRecord(dsh?.client) ? dsh.client : undefined
+    if (client === undefined) continue
+    target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
+    target.changed = normalizeClientArray(
+      client,
+      'external',
+      value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
+    ) || target.changed
+  }
+
+  const staticInputs = new Set([
+    ...facts.staticLinkedPackages,
+    ...facts.platformModules.map(packageNameOf),
+  ])
+  staticInputs.delete(CORDIS)
+  const inferredRanges = dependencyRangeCandidates(root)
+  for (const pkg of facts.packages) {
+    const target = document(pkg.manifest)
+    const expected = expectedSections(pkg, staticInputs)
+    for (const [name, rule] of expected) {
+      const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
+      if (range === undefined) continue
+      target.changed = rule.kind === 'dev'
+        ? ensureDevOnly(target.manifest, name, range) || target.changed
+        : ensurePeerDev(target.manifest, name, range) || target.changed
+    }
+
+    if (pkg.dynamic) {
+      const productionNames = new Set([
+        ...Object.keys(section(target.manifest, 'dependencies')),
+        ...Object.keys(section(target.manifest, 'peerDependencies')),
+      ])
+      for (const name of productionNames) {
+        if (expected.has(name)) continue
+        const range = preferredRange(
+          target.manifest,
+          name,
+          staticInputs.has(name) ? 'dev' : 'peer-dev',
+          inferredRanges,
+        )
+        if (range === undefined) continue
+        if (staticInputs.has(name)) {
+          target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
+        } else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
+          target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
+        }
+      }
+    }
+
+    for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
+      target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
+    }
+    target.changed = deleteEmptySections(target.manifest) || target.changed
+  }
+
+  const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
+    left.path.localeCompare(right.path))
+  for (const target of changed) {
+    writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
+  }
+  return changed.map(target => target.path)
+}
+
+function normalizeClientArray(
+  client: Record<string, unknown>,
+  field: 'external' | 'inject',
+  remove: (value: string) => boolean,
+): boolean {
+  const value = client[field]
+  if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
+  const seen = new Set<string>()
+  const normalized = value.filter((entry: string) => {
+    if (entry === '' || seen.has(entry) || remove(entry)) return false
+    seen.add(entry)
+    return true
+  })
+  if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
+  if (normalized.length === 0) {
+    if (field === 'external') delete client.external
+    else delete client.inject
+  } else {
+    client[field] = normalized
+  }
+  return true
+}
+
+function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
+  let changed = deleteDependency(manifest, 'dependencies', name)
+  changed = deleteDependency(manifest, 'peerDependencies', name) || changed
+  return setDependency(manifest, 'devDependencies', name, range) || changed
+}
+
+function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
+  let changed = deleteDependency(manifest, 'dependencies', name)
+  changed = setDependency(manifest, 'peerDependencies', name, range) || changed
+  return setDependency(manifest, 'devDependencies', name, range) || changed
+}
+
+function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
+  const dependencies = mutableSection(manifest, field)
+  if (dependencies[name] === range) return false
+  dependencies[name] = range
+  return true
+}
+
+function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
+  const dependencies = section(manifest, field)
+  if (dependencies[name] === undefined) return false
+  manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
+  return true
+}
+
+function deleteEmptySections(manifest: Manifest): boolean {
+  let changed = false
+  for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
+    if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
+    if (field === 'dependencies') delete manifest.dependencies
+    else if (field === 'peerDependencies') delete manifest.peerDependencies
+    else delete manifest.devDependencies
+    changed = true
+  }
+  return changed
+}
+
+function preferredRange(
+  manifest: Manifest,
+  name: string,
+  kind: ExpectedRule['kind'],
+  inferred: ReadonlyMap<string, ReadonlySet<string>>,
+): string | undefined {
+  const order: readonly DependencySection[] = kind === 'dev'
+    ? ['devDependencies', 'peerDependencies', 'dependencies']
+    : ['peerDependencies', 'devDependencies', 'dependencies']
+  for (const field of order) {
+    const range = section(manifest, field)[name]
+    if (range !== undefined) return range
+  }
+  if (isInternalDsh(name)) return 'workspace:^'
+  const candidates = inferred.get(name)
+  return candidates?.size === 1 ? [...candidates][0] : undefined
+}
+
+function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
+  const candidates = new Map<string, Set<string>>()
+  const paths = globSync([
+    'package.json',
+    ...MANIFEST_GLOBS,
+    'website/package.json',
+  ], { cwd: root }).map(normalizePath)
+  for (const path of new Set(paths)) {
+    const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
+    for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
+      for (const [name, range] of Object.entries(section(manifest, field))) {
+        const ranges = candidates.get(name) ?? new Set<string>()
+        ranges.add(range)
+        candidates.set(name, ranges)
+      }
+    }
+  }
+  return candidates
+}
+
+function section(manifest: Manifest, field: DependencySection): Record<string, string> {
+  return manifest[field] ?? {}
+}
+
+function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
+  const value = manifest[field]
+  if (value !== undefined) return value
+  const created: Record<string, string> = {}
+  manifest[field] = created
+  return created
+}
+
+function collectModeViolations(facts: ClientPackageFacts): string[] {
+  const violations: string[] = []
+  for (const pkg of facts.packages) {
+    if (pkg.dynamic && pkg.staticLinked) {
+      violations.push(
+        pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
+        + ' a client package must be dynamic or statically linked, not both',
+      )
+    } else if (!pkg.dynamic && !pkg.staticLinked) {
+      violations.push(
+        pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
+        + ' declare dsh.client or use the staticLinked preset',
+      )
+    }
+  }
+
+  const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
+  for (const specifier of facts.platformModules) {
+    const owner = packageNameOf(specifier)
+    if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
+    violations.push(
+      PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
+      + ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
+    )
+  }
+
+  const rows = rowNames(facts.declarations)
+  for (const specifier of facts.preloadedExternals) {
+    if (rowPackageOf(specifier, rows) !== undefined) continue
+    violations.push(
+      PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+      + ' has no dynamic dsh.client row',
+    )
+  }
+  return violations
+}
+
+interface ExpectedRule {
+  readonly kind: 'dev' | 'peer-dev'
+  readonly origins: Set<string>
+}
+
+function collectDependencyViolations(facts: ClientPackageFacts): string[] {
+  const violations: string[] = []
+  const staticInputs = new Set([
+    ...facts.staticLinkedPackages,
+    ...facts.platformModules.map(packageNameOf),
+  ])
+  staticInputs.delete(CORDIS)
+
+  for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
+    const expected = expectedSections(pkg, staticInputs)
+    for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
+      const actual = declaredSections(pkg, name)
+      if (rule.kind === 'dev') {
+        if (actual.length === 1 && actual[0] === 'devDependencies') continue
+        violations.push(
+          pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
+          + ' declare it only in devDependencies, found ' + describeSections(actual),
+        )
+        continue
+      }
+
+      const peerRange = pkg.peerDependencies[name]
+      const devRange = pkg.devDependencies[name]
+      if (actual.length === 2
+        && actual.includes('peerDependencies')
+        && actual.includes('devDependencies')
+        && peerRange === devRange) continue
+      violations.push(
+        pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
+        + ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
+        + ' with matching ranges, not dependencies; found ' + describeSections(actual)
+        + describeRangeMismatch(peerRange, devRange),
+      )
+    }
+
+    for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
+      if (expected.has(name)) continue
+      const devRange = pkg.devDependencies[name]
+      if (devRange === peerRange) continue
+      violations.push(
+        pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
+        + ' must use the same range; found ' + (devRange ?? 'no declaration'),
+      )
+    }
+
+    if (!pkg.dynamic) continue
+    for (const section of ['dependencies', 'peerDependencies'] as const) {
+      for (const name of Object.keys(pkg[section]).sort()) {
+        if (expected.has(name)) continue
+        if (staticInputs.has(name)) {
+          violations.push(
+            pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
+            + ' move it to devDependencies or delete the stale declaration',
+          )
+        } else if (section === 'dependencies' && isInternalDsh(name)) {
+          violations.push(
+            pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
+            + ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
+          )
+        }
+      }
+    }
+  }
+  return violations
+}
+
+function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
+  const expected = new Map<string, ExpectedRule>([
+    [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
+  ])
+  if (!pkg.dynamic) return expected
+
+  const add = (name: string, origin: string): void => {
+    if (name === pkg.name) return
+    const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
+    if (kind === undefined) return
+    const current = expected.get(name)
+    if (current !== undefined) current.origins.add(origin)
+    else expected.set(name, { kind, origins: new Set([origin]) })
+  }
+  for (const [name, locations] of Object.entries(pkg.sourceUses)) {
+    for (const location of locations) add(name, location)
+  }
+  for (const name of pkg.inject) add(name, 'dsh.client.inject')
+  return expected
+}
+
+interface ModuleEdge {
+  readonly from: string
+  readonly to: string
+  readonly specifier: string
+}
+
+function collectModuleViolations(facts: ClientPackageFacts): string[] {
+  const violations: string[] = []
+  const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
+  const staticModules = new Set(facts.platformModules)
+  const rows = rowNames(facts.declarations)
+  const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
+  const edges: ModuleEdge[] = []
+
+  for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
+    for (const field of ['external', 'inject'] as const) {
+      const seen = new Set<string>()
+      for (const value of pkg[field]) {
+        if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
+        else if (seen.has(value)) {
+          violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
+        }
+        seen.add(value)
+      }
+    }
+
+    for (const specifier of new Set(pkg.external)) {
+      if (specifier === '') continue
+      if (baseline.has(specifier)) {
+        violations.push(
+          pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
+          + '; remove the explicit declaration',
+        )
+        continue
+      }
+      if (staticModules.has(specifier)) continue
+      const supplier = rowPackageOf(specifier, rows)
+      if (supplier === pkg.name) {
+        violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
+      } else if (supplier !== undefined) {
+        edges.push({ from: pkg.name, to: supplier, specifier })
+      } else {
+        const owner = stripClientSuffix(specifier)
+        violations.push(
+          pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
+          + (byName.has(owner)
+            ? ' workspace package ' + owner
+              + ' declares no dynamic dsh.client row and the shell does not seed this specifier'
+            : ' no dynamic row or PLATFORM_MODULES entry answers it'),
+        )
+      }
+    }
+  }
+
+  violations.push(...collectModuleCycles(edges, byName))
+  return violations
+}
+
+function collectModuleCycles(
+  edges: readonly ModuleEdge[],
+  byName: ReadonlyMap<string, ClientDeclaration>,
+): string[] {
+  const outgoing = new Map<string, ModuleEdge[]>()
+  for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
+    outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
+  }
+  const finished = new Set<string>()
+  const onPath = new Set<string>()
+  const path: ModuleEdge[] = []
+  const reported = new Map<string, string>()
+
+  const walk = (name: string): void => {
+    onPath.add(name)
+    for (const edge of outgoing.get(name) ?? []) {
+      if (onPath.has(edge.to)) {
+        const start = path.findIndex(entry => entry.from === edge.to)
+        const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
+        const key = cycleKey(cycle)
+        if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
+      } else if (!finished.has(edge.to)) {
+        path.push(edge)
+        walk(edge.to)
+        path.pop()
+      }
+    }
+    onPath.delete(name)
+    finished.add(name)
+  }
+
+  for (const name of [...outgoing.keys()].sort()) {
+    if (!finished.has(name)) walk(name)
+  }
+  return [...reported.values()]
+}
+
+function cycleKey(cycle: readonly ModuleEdge[]): string {
+  const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
+  const first = [...labels].sort()[0]
+  const offset = first === undefined ? 0 : labels.indexOf(first)
+  return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
+}
+
+function formatCycle(
+  cycle: readonly ModuleEdge[],
+  byName: ReadonlyMap<string, ClientDeclaration>,
+): string {
+  const entry = cycle[0]
+  const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
+  const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
+  return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
+}
+
+interface Manifest {
+  name?: unknown
+  dsh?: unknown
+  dependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+  devDependencies?: Record<string, string>
+}
+
+function readDeclaration(
+  root: string,
+  manifestPath: string,
+  malformed: string[],
+): ClientDeclaration | undefined {
+  const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
+  if (typeof manifest.name !== 'string') return undefined
+  const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
+  const rawClient = dsh?.client
+  if (rawClient === undefined) {
+    return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
+  }
+  if (!isRecord(rawClient)) {
+    malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
+    return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
+  }
+  return {
+    name: manifest.name,
+    manifest: manifestPath,
+    dynamic: true,
+    external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
+    inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
+  }
+}
+
+function stringArray(
+  value: unknown,
+  packageName: string,
+  manifestPath: string,
+  field: string,
+  malformed: string[],
+): readonly string[] {
+  if (value === undefined) return []
+  if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
+    malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
+    return []
+  }
+  return value as string[]
+}
+
+async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
+  const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
+  const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
+  if (typeof preset.isStaticLinkedConfig !== 'function') {
+    throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
+  }
+  const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
+  const roster = new Set<string>()
+  for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
+    const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
+    if (typeof loaded.default !== 'function') continue
+    const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
+      env: { DSH_BUILD_FACE: 'client' },
+    })
+    if (!Array.isArray(configs) || !predicate(configs)) continue
+    const manifest = JSON.parse(
+      readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
+    ) as Manifest
+    if (typeof manifest.name === 'string') roster.add(manifest.name)
+  }
+  return roster
+}
+
+function readStringLiteralArray(root: string, name: string): string[] {
+  const path = resolve(root, PLATFORM_SOURCE)
+  const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
+  for (const statement of source.statements) {
+    if (!ts.isVariableStatement(statement)) continue
+    for (const declaration of statement.declarationList.declarations) {
+      if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
+      const expression = declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer)
+        ? declaration.initializer.expression
+        : declaration.initializer
+      if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
+        throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must be an array literal')
+      }
+      return expression.elements.map((element) => {
+        if (!ts.isStringLiteral(element)) {
+          throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must contain only string literals')
+        }
+        return element.text
+      })
+    }
+  }
+  throw new Error(GATE + ': ' + PLATFORM_SOURCE + ' declares no ' + name)
+}
+
+async function readFacts(root: string): Promise<ClientPackageFacts> {
+  const { declarations, malformed } = readClientDeclarations(root)
+  const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
+  const staticLinkedPackages = await readStaticLinkedRoster(root)
+  const project = new TypeScriptProject(root, 'client')
+  const packages: ClientPackage[] = []
+
+  for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
+    const declaration = byManifest.get(manifestPath)
+    if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
+    const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
+    if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
+    const sourceUses = new Map<string, Set<string>>()
+    const packageDirectory = dirname(manifestPath)
+    const sourcePrefix = packageDirectory + '/src/'
+    for (const sourceFile of project.sourceFiles()) {
+      if (sourceFile.isDeclarationFile) continue
+      const file = project.relativePath(sourceFile)
+      if (!file.startsWith(sourcePrefix)) continue
+      for (const name of collectSourceFilePackageUses(sourceFile)) {
+        const locations = sourceUses.get(name) ?? new Set<string>()
+        locations.add(file)
+        sourceUses.set(name, locations)
+      }
+    }
+    packages.push({
+      ...declaration,
+      staticLinked: staticLinkedPackages.has(declaration.name),
+      sourceUses: Object.fromEntries(
+        [...sourceUses].sort(([left], [right]) => left.localeCompare(right))
+          .map(([name, locations]) => [name, [...locations].sort()]),
+      ),
+      dependencies: manifest.dependencies ?? {},
+      peerDependencies: manifest.peerDependencies ?? {},
+      devDependencies: manifest.devDependencies ?? {},
+    })
+  }
+
+  return {
+    packages,
+    declarations,
+    staticLinkedPackages,
+    platformModules: readStringLiteralArray(root, 'PLATFORM_MODULES'),
+    preloadedExternals: readStringLiteralArray(root, 'PRELOADED_CLIENT_EXTERNALS'),
+    malformed,
+  }
+}
+
+function packageNameOf(specifier: string): string {
+  const segments = specifier.split('/')
+  return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
+}
+
+function stripClientSuffix(specifier: string): string {
+  return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
+}
+
+function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
+  return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
+}
+
+function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
+  if (rows.has(specifier)) return specifier
+  const stripped = stripClientSuffix(specifier)
+  return rows.has(stripped) ? stripped : undefined
+}
+
+function declaredSections(pkg: ClientPackage, name: string): string[] {
+  return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
+    .filter(section => pkg[section][name] !== undefined)
+}
+
+function describeSections(sections: readonly string[]): string {
+  return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
+}
+
+function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
+  if (peer === undefined || dev === undefined || peer === dev) return ''
+  return ' (peer ' + peer + ', dev ' + dev + ')'
+}
+
+function describeOrigins(origins: ReadonlySet<string>): string {
+  const sorted = [...origins].sort()
+  const [first, second, ...rest] = sorted
+  if (first === undefined) return 'production use'
+  if (second === undefined) return first
+  return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
+}
+
+function isInternalDsh(name: string): boolean {
+  return name === CORDIS || name.startsWith(DSH_PREFIX)
+}
+
+function isBareSpecifier(specifier: string): boolean {
+  return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function normalizePath(path: string): string {
+  return path.split(sep).join('/')
+}
+
+async function main(): Promise<void> {
+  const root = resolve(import.meta.dirname, '..')
+  let facts = await readFacts(root)
+  if (process.argv.includes('--fix')) {
+    const changed = fixClientPackageManifests(root, facts)
+    console.log(
+      changed.length === 0
+        ? GATE + ': no mechanically fixable manifest changes.'
+        : GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
+    )
+    facts = await readFacts(root)
+  }
+  const violations = collectClientPackageViolations(facts)
+  if (violations.length > 0) {
+    console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
+    for (const violation of violations) console.error('  ' + violation)
+    process.exit(1)
+  }
+
+  const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
+  const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
+  console.log(
+    GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
+    + String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
+    + String(requests) + ' explicit external request(s).',
+  )
+}
+
+if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
+  await main()
+}

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

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

+ 3 - 0
tsconfig.base.json

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

+ 4 - 0
vitest.config.ts

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

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно