Selaa lähdekoodia

feat(gui): dsh-client-hmr reload driver and the dshClient-discovered watch-build script

client-hmr is a normal plugin package composed into dev graphs only. It
listens on /plugins/events and reloads one plugin per rebuilt frame,
serialized: invalidate, prefetch (fresh factory registers while the old
fiber still serves), registry.delete before touching the fiber, drain
disposers, drop owned style tags, entry.refresh(), fiber.await() loud.
Dependency cascade costs zero client code — fiber activation epochs
re-load dependents through cordis itself. Reload is coarse by design;
no rollback in v1; self-reload works with a frame gap the next rebuild
heals.

scripts/dev-web.ts (pnpm run dev:web) is the convenience watch-build:
it discovers its package list by scanning packages/*/*/package.json for
dshClient platform "web" at startup — no hardcoded roster — and talks
no protocol to the host. Gate bookkeeping rides along: knip entries for
the new packages, README model-experience allowlist rows.
imccyu 1 kuukausi sitten
vanhempi
sitoutus
6512e57047

+ 32 - 11
knip.json

@@ -18,7 +18,8 @@
   "workspaces": {
     ".": {
       "entry": [
-        "scripts/**/*.mjs"
+        "scripts/**/*.mjs",
+        "scripts/dev-web.ts"
       ],
       "project": [
         "scripts/**/*.ts",
@@ -66,15 +67,11 @@
     },
     "packages/host/runtime": {
       "entry": [
-        "tests/**/*.spec.ts",
-        "tests/**/*.e2e.ts"
+        "tests/**/*.spec.ts"
       ],
       "project": [
         "src/**/*.ts",
         "tests/**/*.ts"
-      ],
-      "ignoreDependencies": [
-        "@deepseek-ai/dsh-client-.+"
       ]
     },
     "packages/client/web-ui": {
@@ -391,8 +388,14 @@
       ]
     },
     "packages/examples/agent-spine-demo": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/ui/jsonrpc": {
       "entry": [
@@ -548,15 +551,15 @@
         "tests/**/*.tsx"
       ],
       "ignoreDependencies": [
-        "@deepseek-ai/dsh-client-ui-theme",
-        "@deepseek-ai/dsh-client-connection"
+        "@deepseek-ai/dsh-client-ui-theme"
       ]
     },
     "apps/web": {
       "entry": [
         "tests/**/*.e2e.ts",
         "tests/**/*.snapshot.ts",
-        "tests/support.ts"
+        "tests/support.ts",
+        "src/node-module-stub.ts"
       ],
       "project": [
         "src/**/*.ts",
@@ -571,6 +574,24 @@
         "react",
         "react-dom"
       ]
+    },
+    "apps/cli": {
+      "project": [
+        "src/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-.+"
+      ]
+    },
+    "packages/client/modules": {
+      "project": [
+        "src/**/*.ts"
+      ]
+    },
+    "packages/client/hmr": {
+      "project": [
+        "src/**/*.ts"
+      ]
     }
   }
 }

+ 1 - 0
package.json

@@ -95,6 +95,7 @@
     "demo:cordis": "node --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
     "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
+    "dev:web": "tsx scripts/dev-web.ts --poll",
     "postinstall": "node scripts/install-lefthook.mjs"
   },
   "devDependencies": {

+ 19 - 0
packages/client/hmr/README.md

@@ -0,0 +1,19 @@
+# @deepseek-ai/dsh-client-hmr
+
+Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
+
+The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
+
+## Model Experience
+
+None, as the reload driver is browser-side machinery; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
+- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
+- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.

+ 51 - 0
packages/client/hmr/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@deepseek-ai/dsh-client-hmr",
+  "description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [],
+    "platform": "web",
+    "immediately": true
+  },
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@cordisjs/plugin-loader": "^1.0.0-rc.5",
+    "@deepseek-ai/dsh-client-modules": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 191 - 0
packages/client/hmr/src/client/index.ts

@@ -0,0 +1,191 @@
+/**
+ * client-hmr, browser half: hot-reload driver for client plugin entries.
+ *
+ * Listens on the host's system SSE channel (`GET /plugins/events`); on a
+ * `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
+ * fiber in place. Every graph entry is a plugin bundle under the web2 model
+ * — `immediately` rows differ only in stage-one prefetch (a boot
+ * optimization), so all nine plugin packages share these reload semantics;
+ * normal packages (react family, cordis, shell, pure libs) are not entries
+ * and shell changes still mean a page reload. Cascade is zero-touch:
+ * downstream fibers key their activation epoch on provider fiber uids
+ * (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
+ * re-cascades natively — reloading a data-layer plugin (connection/runtime)
+ * cascades into its UI dependents with no HMR-side bookkeeping.
+ *
+ * Reload order (lazy CJS table): invalidate (drop the stale factory and
+ * materialized record) → prefetch (fetch + execute + register the fresh
+ * factory) → registry-first teardown → drain old fiber unload → remove
+ * owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
+ * factory. Invalidate MUST precede prefetch: a live factory makes prefetch
+ * a no-op, and re-executing a bundle over an undeleted registration is a
+ * loud duplicate. The swap is safe because execution is pure registration
+ * under the lazy model — every module side effect (CSS injection included)
+ * lives in the factory closure and runs at materialization, inside
+ * refresh(). That also keeps the CSS ordering guarantee: owned styles are
+ * removed after the old fiber's disposers drained (SlotCore one-owner
+ * unregister) and before materialization re-injects tags under the same
+ * stable tag ids.
+ *
+ * Failure window: if prefetch rejects after invalidate, the module is left
+ * unregistered while the OLD fiber keeps running untouched (teardown never
+ * started) — degraded but recoverable, the next rebuilt frame retries from
+ * scratch. Consistent with the v1 no-rollback policy below. Known dev-only
+ * race: a rebuilt frame overlapping a still-in-flight boot arrival shares
+ * that arrival's task and may materialize the pre-rebuild bytes; the next
+ * rebuilt frame self-heals.
+ *
+ * Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
+ * confirmed against vendor sources:
+ * 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
+ *    entry.ts assigns it only in `_init`), so `refresh()` hits its
+ *    `if (this.fiber) return` guard and no-ops.
+ * 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
+ *    (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
+ *    still holds the runtime at emit time), which flags the entry
+ *    `disabled: true` — permanently.
+ * vendor/hmr's reload skeleton documents the fix: delete the runtime record
+ * FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
+ * then rebuild. We additionally clear `entry.fiber` ourselves so
+ * `entry.refresh()` re-imports and re-plugins through the Loader's own
+ * `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
+ * of hand-rolling `registry.plugin`. Client entries have exactly one fiber
+ * per runtime, so `registry.delete` never collaterally disposes siblings.
+ *
+ * Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
+ * name it. The in-flight reload keeps running in the old bundle's closure
+ * (its EventSource closes with the old fiber's effects); the new bundle's
+ * apply opens a fresh channel. Frames arriving during the gap are lost —
+ * acceptable for the dev channel, the next rebuild renotifies.
+ *
+ * Failure policy (v1): no rollback. An import failure leaves the entry
+ * fiberless (the next rebuilt frame retries from scratch); an apply failure
+ * leaves a FAILED fiber for the shell's status projection. Both log loudly.
+ */
+import type { Context } from 'cordis'
+import type { Entry, Loader } from '@cordisjs/plugin-loader'
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
+
+/**
+ * Frames on the `GET /plugins/events` system SSE channel (owned host-side by
+ * dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
+ * wire boundary: frames arrive as JSON text and are validated at the parse
+ * point, not shared as a same-process typed seam.
+ */
+export type PluginsEventFrame =
+  | { type: 'graph'; graph: WebBootGraph }
+  | { type: 'rebuilt'; id: string; rev: string }
+
+/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
+export const EVENTS_ENDPOINT = '/plugins/events'
+
+/** Cordis plugin name. */
+export const name = 'client-hmr'
+
+/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
+export const inject = ['loader', 'modules']
+
+/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
+function findEntry(loader: Loader, id: string): Entry | undefined {
+  for (const entry of loader.entries()) {
+    if (entry.options.name === id) return entry
+  }
+  return undefined
+}
+
+/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
+function removeOwnedStyles(id: string): void {
+  for (const el of document.querySelectorAll('style[data-plugin]')) {
+    if (el.getAttribute('data-plugin') === id) el.remove()
+  }
+}
+
+/**
+ * Mount the HMR driver: subscribe to the system SSE channel and hot-swap
+ * rebuilt entries.
+ * @param ctx - plugin context with `loader` and `modules` available.
+ */
+export function apply(ctx: Context): void {
+  // Both are declared injections (typed Context merges: `modules` from the
+  // client module loader package, `loader` from the vendored Loader).
+  const modLoader = ctx.modules
+  const loader: Loader = ctx.loader
+
+  async function reload(id: string): Promise<void> {
+    const entry = findEntry(loader, id)
+    if (entry === undefined) {
+      ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
+      return
+    }
+    // Invalidate first (drop stale factory + record — a live factory makes
+    // prefetch a no-op and re-registration a loud duplicate), then run the
+    // async half while the old fiber still serves: fetch + execute registers
+    // the fresh factory with zero side effects (lazy CJS — module bodies run
+    // at materialization, not execution).
+    modLoader.invalidate(id)
+    await modLoader.prefetch(id)
+
+    const oldFiber = entry.fiber
+    if (oldFiber !== undefined) {
+      // Registry-first teardown (see module comment): the runtime record must
+      // be gone before the fiber's disposer emits internal/plugin, or the
+      // Loader flags the entry disabled.
+      const runtime = oldFiber.runtime
+      if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
+      // Drain the unload: effect disposers (slots, subscriptions) must finish
+      // before the new bundle executes and the new apply re-registers.
+      while (oldFiber.inertia !== undefined) await oldFiber.inertia
+      delete entry.fiber
+    }
+    // Old owned styles go before materialization re-injects them (the CSS
+    // idempotency guard keys on stable tag ids).
+    removeOwnedStyles(id)
+    // Re-init through the entry: fiber cleared above, so refresh() re-imports
+    // — materializing the prefetched factory (CSS injects here) — and
+    // re-plugins under the entry context. Import failures are logged by
+    // Entry._init and leave the entry fiberless (retryable).
+    await entry.refresh()
+    // Surface apply failures loudly (v1: no rollback, FAILED state stays).
+    await entry.fiber?.await()
+  }
+
+  // Serialize reloads: frames can arrive faster than a swap completes, and
+  // interleaved dispose/execute chains would corrupt the single-slot handoff.
+  let queue: Promise<void> = Promise.resolve()
+  const handle = (frame: PluginsEventFrame): void => {
+    switch (frame.type) {
+      case 'rebuilt':
+        queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
+          ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
+          ctx.logger.error(error)
+        })
+        break
+      case 'graph':
+        // Connect-time snapshot, unused in v1. The loader's cached graph rev
+        // goes stale after rebuilds — harmless, since prefetch hits the
+        // network anyway (host serves bundles no-cache); graph rev refresh
+        // lands with the reconnect-handshake mechanism.
+        break
+      default:
+        // Merge-extensible frame union: unknown frame types from newer hosts
+        // are ignored by design.
+        break
+    }
+  }
+
+  ctx.effect(() => {
+    const source = new EventSource(EVENTS_ENDPOINT)
+    source.addEventListener('message', (event: MessageEvent<string>) => {
+      let frame: PluginsEventFrame
+      try {
+        frame = JSON.parse(event.data) as PluginsEventFrame
+      } catch {
+        // Wire boundary: a malformed dev-channel frame is dropped loudly.
+        ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
+        return
+      }
+      handle(frame)
+    })
+    return () => { source.close() }
+  }, 'client-hmr: event source')
+}

+ 9 - 0
packages/client/hmr/src/index.ts

@@ -0,0 +1,9 @@
+/**
+ * HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
+ * the host graph): the reload driver lives in its client half in full
+ * (src/client/); the empty apply exists so the plugin appears in the host
+ * Loader (lifecycle governance + dshClient discovery).
+ */
+
+/** Host plugin body — no host-side behavior for the HMR plugin. */
+export function apply(): void {}

+ 33 - 0
packages/client/hmr/src/invariant.ts

@@ -0,0 +1,33 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
+ * @module @deepseek-ai/dsh-client-hmr/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
+
+/** Cordis companion plugin name. */
+export const name = 'client-hmr-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: a dev-only reload driver — it consumes the loader
+ * entry tree and module cache but owns no events and no cross-plugin mutable
+ * state; reload correctness (dispose → style removal → re-execute ordering)
+ * is observable only through the assembled browser runtime, not a host-side
+ * event relation.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 30 - 0
packages/client/hmr/tsconfig.json

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

+ 3 - 0
packages/client/hmr/tsdown.config.ts

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

+ 93 - 65
pnpm-lock.yaml

@@ -101,6 +101,33 @@ importers:
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../../packages/ui/app-boot
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../../packages/client/connection
+      '@deepseek-ai/dsh-client-hmr':
+        specifier: workspace:^
+        version: link:../../packages/client/hmr
+      '@deepseek-ai/dsh-client-i18n':
+        specifier: workspace:^
+        version: link:../../packages/client/i18n
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../../packages/client/runtime
+      '@deepseek-ai/dsh-client-ui-conversation':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-conversation
+      '@deepseek-ai/dsh-client-ui-layout':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-layout
+      '@deepseek-ai/dsh-client-ui-sidebar':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-sidebar
+      '@deepseek-ai/dsh-client-ui-theme':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-theme
+      '@deepseek-ai/dsh-client-ui-trajectory':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-trajectory
       '@deepseek-ai/dsh-frontend':
         specifier: workspace:^
         version: link:../web
@@ -509,6 +536,21 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/client/hmr:
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../modules
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
   packages/client/i18n:
     dependencies:
       '@deepseek-ai/dsh-client-runtime':
@@ -522,6 +564,15 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/client/modules:
+    devDependencies:
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/client/runtime:
     dependencies:
       '@deepseek-ai/dsh-client-connection':
@@ -530,6 +581,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-host-apiproxy':
+        specifier: workspace:^
+        version: link:../../host/apiproxy
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -558,6 +612,13 @@ importers:
 
   packages/client/ui-conversation:
     dependencies:
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+    devDependencies:
+      '@deepseek-ai/dsh-client-i18n':
+        specifier: workspace:^
+        version: link:../i18n
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -570,13 +631,6 @@ importers:
       '@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/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -586,19 +640,18 @@ importers:
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-layout:
-    dependencies:
+    devDependencies:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
-    devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -608,6 +661,9 @@ importers:
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-primitives:
     dependencies:
@@ -685,6 +741,10 @@ importers:
 
   packages/client/ui-sidebar:
     dependencies:
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
+    devDependencies:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -697,13 +757,6 @@ importers:
       '@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/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -713,6 +766,9 @@ importers:
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/ui-slots:
     devDependencies:
@@ -736,17 +792,16 @@ importers:
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
   packages/client/ui-trajectory:
-    dependencies:
-      '@deepseek-ai/dsh-client-ui-conversation':
-        specifier: workspace:^
-        version: link:../ui-conversation
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
     devDependencies:
       '@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-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -756,15 +811,15 @@ importers:
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/client/web:
     dependencies:
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
-      '@deepseek-ai/dsh-client-runtime':
+      '@deepseek-ai/dsh-client-modules':
         specifier: workspace:^
-        version: link:../runtime
+        version: link:../modules
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives
@@ -784,6 +839,12 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
     devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -795,7 +856,7 @@ importers:
         version: 18.3.7(@types/react@18.3.31)
       cordis:
         specifier: ^4.0.0-rc.7
-        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
       typescript:
         specifier: ^6.0.3
         version: 6.0.3
@@ -2027,33 +2088,6 @@ importers:
       '@deepseek-ai/dsh-bash-local':
         specifier: workspace:^
         version: link:../../bash/bash-local
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../../client/connection
-      '@deepseek-ai/dsh-client-i18n':
-        specifier: workspace:^
-        version: link:../../client/i18n
-      '@deepseek-ai/dsh-client-runtime':
-        specifier: workspace:^
-        version: link:../../client/runtime
-      '@deepseek-ai/dsh-client-ui-conversation':
-        specifier: workspace:^
-        version: link:../../client/ui-conversation
-      '@deepseek-ai/dsh-client-ui-layout':
-        specifier: workspace:^
-        version: link:../../client/ui-layout
-      '@deepseek-ai/dsh-client-ui-question':
-        specifier: workspace:^
-        version: link:../../client/ui-question
-      '@deepseek-ai/dsh-client-ui-sidebar':
-        specifier: workspace:^
-        version: link:../../client/ui-sidebar
-      '@deepseek-ai/dsh-client-ui-theme':
-        specifier: workspace:^
-        version: link:../../client/ui-theme
-      '@deepseek-ai/dsh-client-ui-trajectory':
-        specifier: workspace:^
-        version: link:../../client/ui-trajectory
       '@deepseek-ai/dsh-compact-basic':
         specifier: workspace:^
         version: link:../../compact/compact-basic
@@ -2147,15 +2181,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
-      '@deepseek-ai/dsh-user-interaction':
-        specifier: workspace:^
-        version: link:../../ui/user-interaction
       '@deepseek-ai/dsh-workflow-workerthread':
         specifier: workspace:^
         version: link:../../workflow/workflow-workerthread
-      '@deepseek-ai/dsh-workspace-context':
-        specifier: workspace:^
-        version: link:../../context/workspace-context
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^

+ 85 - 0
scripts/dev-web.ts

@@ -0,0 +1,85 @@
+/**
+ * Watch-build for client-plugin HMR: runs every dshClient 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 --dev`), 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
+ * events, so native watching sees the initial build only and never a source
+ * change.
+ *
+ * Each package keeps its own tsdown.config.ts untouched: this script layers
+ * `watch` through API-level inline config (tsdown workspace mode fills inline
+ * keys under each package's file config, and no package config defines it).
+ */
+import { readdirSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { build } from 'tsdown'
+
+const repoRoot = fileURLToPath(new URL('..', import.meta.url))
+
+/**
+ * Discover the watch workspace by declaration: every packages/<group>/<name>
+ * whose package.json carries `dshClient` with platform "web" is a client
+ * plugin bundle emitter. Scanned once at startup — a package added while
+ * watching means restarting this script.
+ * @returns workspace-relative plugin package directories.
+ */
+function discoverPluginDirs(): string[] {
+  const dirs: string[] = []
+  for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) {
+    if (!group.isDirectory()) continue
+    for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) {
+      if (!pkg.isDirectory()) continue
+      let manifest: { dshClient?: { platform?: unknown } }
+      try {
+        manifest = JSON.parse(
+          readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'),
+        ) as { dshClient?: { platform?: unknown } }
+      } catch {
+        continue // no package.json (support dirs, scratch): not a workspace package
+      }
+      if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`)
+    }
+  }
+  return dirs
+}
+
+const PLUGIN_DIRS = discoverPluginDirs()
+if (PLUGIN_DIRS.length === 0) {
+  console.error('dev-web: no dshClient (platform "web") packages found under packages/')
+  process.exit(1)
+}
+
+const args = process.argv.slice(2)
+const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
+if (args.some(a => a !== pollArg)) {
+  console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
+  process.exit(1)
+}
+const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
+if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
+  console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
+  process.exit(1)
+}
+
+await build({
+  cwd: repoRoot,
+  workspace: PLUGIN_DIRS,
+  watch: true,
+  // Rolldown watch options ride through inputOptions (tsdown has no watcher
+  // tuning of its own); polling is opt-in for network mounts without inotify.
+  ...pollInterval !== undefined
+    ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
+    : {},
+})
+console.log(
+  `dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages`
+  + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n  ${PLUGIN_DIRS.join('\n  ')}`,
+)

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

@@ -45,6 +45,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
   'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
+  'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
+  'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
   'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },