Forráskód Böngészése

Merge branch 'master' into worktree-wspace-storage

Tianyi Cui 1 hónapja
szülő
commit
2cb23a8481
78 módosított fájl, 2699 hozzáadás és 2518 törlés
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 7 7
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 7 7
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 42 0
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 42 0
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 224 0
      apps/cli/cordis.yml
  8. 44 1
      apps/cli/package.json
  9. 231 0
      apps/cli/src/app-cli-entry.ts
  10. 29 123
      apps/cli/src/web.ts
  11. 0 1
      apps/web/package.json
  12. 2 2
      apps/web/src/main.ts
  13. 5 3
      apps/web/tests/session-title.snapshot.ts
  14. 0 291
      apps/web/tests/smoke-fixture.e2e.ts
  15. 1 4
      apps/web/tests/support.ts
  16. 0 3
      apps/web/tsconfig.json
  17. 1 1
      apps/web/vite.config.ts
  18. 14 0
      docs/capability-seams.md
  19. 48 5
      docs/config-catalog.md
  20. 68 0
      docs/cordis-catalog/services.md
  21. 6 6
      docs/event-producer-consumer.md
  22. 5 3
      docs/module-graph.md
  23. 3 6
      knip.json
  24. 2 0
      packages/client/connection/package.json
  25. 8 0
      packages/client/connection/src/api-path.ts
  26. 59 0
      packages/client/connection/src/http-bridge.ts
  27. 33 7
      packages/client/connection/src/index.ts
  28. 4 3
      packages/client/connection/src/invariant.ts
  29. 29 6
      packages/client/connection/tests/node-half.spec.ts
  30. 5 1
      packages/client/connection/tsconfig.json
  31. 5 0
      packages/client/hmr/package.json
  32. 4 13
      packages/client/hmr/src/client/index.ts
  33. 16 0
      packages/client/hmr/src/events.ts
  34. 149 6
      packages/client/hmr/src/index.ts
  35. 35 9
      packages/client/hmr/src/invariant.ts
  36. 110 8
      packages/client/hmr/tests/node-half.spec.ts
  37. 7 1
      packages/client/hmr/tsconfig.json
  38. 17 1
      packages/client/modules/package.json
  39. 34 0
      packages/client/modules/src/client/index.ts
  40. 243 0
      packages/client/modules/src/client/manifest.ts
  41. 17 25
      packages/client/modules/src/client/system.ts
  42. 354 136
      packages/client/modules/src/index.ts
  43. 18 7
      packages/client/modules/src/invariant.ts
  44. 12 17
      packages/client/modules/tests/loader.spec.ts
  45. 7 15
      packages/client/modules/tsconfig.json
  46. 3 0
      packages/client/modules/tsdown.config.ts
  47. 1 1
      packages/client/web/README.md
  48. 209 143
      packages/client/web/src/boot.tsx
  49. 3 3
      packages/client/web/src/index.ts
  50. 48 0
      packages/cordis/tool-cordis/src/api-catalog.ts
  51. 2 2
      packages/host/apiproxy/README.md
  52. 5 1
      packages/host/apiproxy/package.json
  53. 9 7
      packages/host/apiproxy/src/api-proxy.ts
  54. 62 5
      packages/host/apiproxy/src/index.ts
  55. 6 5
      packages/host/apiproxy/src/invariant.ts
  56. 15 0
      packages/host/apiproxy/tsconfig.json
  57. 1 1
      packages/host/runtime/README.md
  58. 0 1
      packages/host/runtime/package.json
  59. 3 6
      packages/host/runtime/src/index.ts
  60. 1 2
      packages/host/runtime/src/start.ts
  61. 0 57
      packages/host/runtime/src/web-plugins.ts
  62. 1 1
      packages/host/runtime/tests/api-proxy-cold.spec.ts
  63. 1 1
      packages/host/runtime/tests/api-proxy-view.spec.ts
  64. 0 111
      packages/host/runtime/tests/web-plugins.spec.ts
  65. 6 8
      packages/host/webserver/README.md
  66. 4 1
      packages/host/webserver/package.json
  67. 154 202
      packages/host/webserver/src/index.ts
  68. 20 18
      packages/host/webserver/src/invariant.ts
  69. 0 56
      packages/host/webserver/src/plugin-events.ts
  70. 0 360
      packages/host/webserver/src/web-plugins.ts
  71. 0 50
      packages/host/webserver/tests/invariant.spec.ts
  72. 0 347
      packages/host/webserver/tests/web-plugins.spec.ts
  73. 0 400
      packages/host/webserver/tests/webserver.spec.ts
  74. 3 0
      packages/host/webserver/tsconfig.json
  75. 155 8
      pnpm-lock.yaml
  76. 2 0
      scripts/gen-cordis-catalog.ts
  77. 16 0
      scripts/gen-doc-graphs.ts
  78. 14 1
      vitest.config.ts

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-23-client-plugin-loading-model.md: 58651fd258a6b2929c58bb6f93b44adb6e8e1818
-2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e
+2026-07-23-client-plugin-loading-model.md: 9f8b69739213b9bdc52e4b4de4d663419e596c66
+2026-07-23-client-plugin-loading-model.zh.md: 05a78fbba9859378178720f012af462382b3ab0f

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

@@ -56,11 +56,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) mounts the roster as in-memory Loader entries via `mountWebPlugins`. The roster is one flat list of the plugin packages, plus the `client-hmr` row under `--dev`. A roster package that fails to import throws loud at mount.
-2. The registry (`createHostWebPluginRegistry`) scans the mounted entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — load-time fail loud.
-3. The registry rescans on cordis `internal/plugin`, microtask-debounced; a rescan failure keeps serving the previous graph. Each bundle's content is hashed into its `rev` (cache busting + HMR diff anchor), and the row set into `graph.rev`. Every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies.
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
+2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
+3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
 
-Why is the roster a hand-written list and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call. The roster lives in `apps/cli/web.ts` rather than cordis.yml only because `dsh web`'s host is a hand-assembled `bootHost` with no Loader config tree yet.
+Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
 **Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
 
@@ -74,9 +74,9 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp
 
 ### Hot reload: one driver plugin, self-watched bundles
 
-Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither.
+Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
 
-How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode the registry stat-polls each scanned bundle file with `fs.watchFile`. Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Watch set membership follows the table: rescans add watches for new rows and drop them for vanished ones, dispose drops all. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
+How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads the graph's bundle paths from `ctx.clientModuleHost.clientPath(id)` and stat-polls each with `fs.watchFile`, following graph membership through `onGraphChanged` (rows added late in the boot window get watches; vanished rows drop them; all lifecycles ride `ctx.effect`). Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change it calls `clientModuleHost.rebuilt(id)` — the single re-hash entry point — and when the `rev` actually changed, broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
 
 On the browser side, the driver reloads one plugin per frame, serialized:
 
@@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif
 
 Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land.
 
-Roster endgame: when `dsh web` moves to config-tree boot, the roster lands in cordis.yml — client plugin packages become ordinary config-tree entry rows, `mountWebPlugins` and the `CLIENT_PACKAGES` constant disappear, and recomposing a deployment means swapping the yml/overlay. The registry needs zero changes for that move, since its `internal/plugin` subscription already discovers whatever entries the tree mounts.
+Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
 
 ## Alternatives considered
 

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

@@ -56,11 +56,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)经 `mountWebPlugins` 把名册挂载为内存中的 Loader entry。名册是插件包的一张平铺清单,`--dev` 下外加 `client-hmr` 行。名册里 import 失败的包在挂载时大声抛错
-2. 注册表(`createHostWebPluginRegistry`)扫描已挂载 entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——装载期大声失败
-3. 注册表在 cordis `internal/plugin` 上重扫,微任务去抖;重扫失败则继续供给上一张图。每个 bundle 的内容哈希进其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`。每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图的类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获
+2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)
+3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)
 
-为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml,只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树
+为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西
 
 **第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
 
@@ -74,9 +74,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
 
 ### 热重载:一个驱动插件,自行监视的 bundle
 
-热重载是否启用是一项组合决策:dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视;prod 图两者皆无。
+热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE 通道;prod 组合不挂载,两者皆无。
 
-重建好的 bundle 怎么变成重载信号?webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下注册表用 `fs.watchFile` 对每个已扫描的 bundle 文件做 stat 轮询。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`);当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。监视集合的成员随表走:重扫为新行添加监视、为消失的行撤下监视,dispose(资源释放)撤掉全部。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径并用 `fs.watchFile` 逐一 stat 轮询,监视集合的成员随 `onGraphChanged` 走(boot 窗口内晚到的行补上监视、消失的行撤下监视,生命周期全部收 `ctx.effect`)。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,它调用 `clientModuleHost.rebuilt(id)`——重哈希的唯一入口;当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE(Server-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
 浏览器侧,驱动插件每帧重载一个插件,串行执行:
 
@@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
 
 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。
 
-名册的终局:当 `dsh web` 迁到配置树 boot,名册落进 cordis.yml——client 插件包变成普通的配置树 entry 行,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。注册表为这次迁移零改动,因为它的 `internal/plugin` 订阅本就发现配置树挂载的任何 entry
+名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半
 
 ## Alternatives considered
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write
+2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa

+ 42 - 0
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -0,0 +1,42 @@
+# Agent Note: dsh web config-tree boot and the web transport layering
+
+Status: implemented
+
+English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
+
+> Scope: how `dsh web` composes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the browser-side loading chain this composition feeds.
+
+## Problem
+
+`dsh web` was the only hand-assembled surface left: `bootHost` mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a `web.ts` constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the `__DSH_BOOT__` graph, owned the SSE channel, and hard-coded the `/api/*` prefix; the dev bundle watch lived inside the prod registry behind a `watch?` flag with no lifecycle owner; the graph registry rescanned everything on every `internal/plugin` emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded `$DSH_HOME/.env`, so `DSH_HOME=… dsh web` could not find an API key living there.
+
+## Decision
+
+**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the ten `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
+
+**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
+
+**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
+
+**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
+
+**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
+
+## Consequences
+
+- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
+- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
+- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Dedicated `dsh-host-profile` receiver package | The profile json is consumed at patch time; the only runtime consumer of `{provider, model}` is the gateway itself — its config is the receiver |
+| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls |
+| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
+| A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half |
+| dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference |
+| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
+| Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
+| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |

+ 42 - 0
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -0,0 +1,42 @@
+# Agent Note:dsh web 的 config-tree boot 与 web 传输分层
+
+Status: implemented
+
+[English](2026-07-24-web-config-tree-boot-and-transport-layering.md) | 中文
+
+> 范围:`dsh web` 如何组合(cordis.yml + cordis 之前的 boot 类 + 配置源),以及 web 传输如何跨包分层(网关 / 载体 / 绑定 / 图 / 开发期重载)。浏览器侧装载链归 [client 插件装载 note](2026-07-23-client-plugin-loading-model.md) 所有,本组合只是它的供给方。
+
+## 问题
+
+`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE 通道、硬编码 `/api/*` 前缀;dev 的 bundle watch 寄居在 prod registry 里靠 `watch?` 参数开关、生命周期无主;图 registry 对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退进程的 sink。还有一个用户可见缺陷:web 路径不装 `$DSH_HOME/.env`,`DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。
+
+## 决策
+
+**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、十个 `dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。
+
+**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
+
+**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
+
+**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
+
+**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。
+
+## 后果
+
+- 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。
+- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
+- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
+
+## Alternatives considered
+
+| 弃案 | 一行理由 |
+|---|---|
+| 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config |
+| runtime 里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住 runtime;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 |
+| 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 |
+| modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 |
+| dev overlay / `cordis.dev.yml` | 一套 yml;`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 |
+| env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
+| create 不等预取(以 `arrive()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
+| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,写入方要懂 cordis |

+ 224 - 0
apps/cli/cordis.yml

@@ -0,0 +1,224 @@
+# dsh web — the full web-shape composition: host runtime (layer 1), the
+# transport/service layer (layer 2), and the browser plugin roster (dshClient
+# rows the modules node half scans into window.__DSH_BOOT__). Row order
+# carries no load semantics (activation is service-availability driven); the
+# grouping below is for readers. `--dev` appends the dsh-client-hmr row in
+# code (AppCLIEntry) — prod and dev differ by exactly that one row.
+# AppCLIEntry patches this tree before boot: profile json + CLI flags +
+# distIndex land as config patches over the rows below (yaml = engineering
+# defaults, json = user config, user wins per field).
+
+# ── layer 1: runtime ────────────────────────────────────────────────────────
+
+- id: timer
+  name: '@cordisjs/plugin-timer'
+
+- id: llm
+  name: '@deepseek-ai/dsh-llm'
+
+- id: session
+  name: '@deepseek-ai/dsh-session'
+
+- id: session-title
+  name: '@deepseek-ai/dsh-session-title'
+  config:
+    fallbackMaxWords: 5
+    fallbackMaxBytes: 40
+    maxTitleBytes: 80
+
+# Model-made titles on the first-message cadence (the web sidebar renders
+# session/title). Same values as the TUI composition.
+- id: session-title-llm
+  name: '@deepseek-ai/dsh-session-title-first-message-llm'
+  config:
+    targetWords: 5
+    targetCjkCharacters: 10
+    maxInputBytes: 4096
+    maxOutputTokens: 64
+    timeoutMs: 60000
+
+- id: system-prompt
+  name: '@deepseek-ai/dsh-system-prompt'
+  config:
+    persona: ''
+
+- id: tools
+  name: '@deepseek-ai/dsh-tools'
+
+- id: user-interaction
+  name: '@deepseek-ai/dsh-user-interaction'
+
+- id: agent
+  name: '@deepseek-ai/dsh-agent'
+
+- id: tasks
+  name: '@deepseek-ai/dsh-tasks'
+
+- id: agent-loop
+  name: '@deepseek-ai/dsh-agent-loop'
+  config:
+    agents: []
+
+# The native DeepSeek adapter; reads the key/base-url the boot's layered
+# .env loading (cwd then $DSH_HOME) left in the environment.
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    apiKey: !!js process.env.DEEPSEEK_API_KEY
+    baseURL: !!js process.env.DEEPSEEK_BASE_URL
+
+- id: session-persistence-jsonl
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: './.sessions'
+
+- id: bash-local
+  name: '@deepseek-ai/dsh-bash-local'
+
+- id: tool-bash
+  name: '@deepseek-ai/dsh-tool-bash'
+
+- id: tool-todo
+  name: '@deepseek-ai/dsh-tool-todo'
+
+- id: tool-tasks
+  name: '@deepseek-ai/dsh-tool-tasks'
+
+# fs cwd stays the package default (process.cwd()) — the same value the
+# gateway injects into session.cwd, so paths and sessions agree.
+- id: fs-local
+  name: '@deepseek-ai/dsh-fs-local'
+
+- id: fs-policy
+  name: '@deepseek-ai/dsh-fs-policy'
+
+- id: tool-fs
+  name: '@deepseek-ai/dsh-tool-fs'
+
+- id: tool-fs-search
+  name: '@deepseek-ai/dsh-tool-fs-search'
+
+- id: workspace-context
+  name: '@deepseek-ai/dsh-workspace-context'
+  config:
+    maxBytes: 65536
+
+- id: skill
+  name: '@deepseek-ai/dsh-skill'
+
+- id: skill-local
+  name: '@deepseek-ai/dsh-skill-local'
+
+- id: tool-skill
+  name: '@deepseek-ai/dsh-tool-skill'
+
+# token-meter rejects unknown config keys — keep this row bare.
+- id: token-meter
+  name: '@deepseek-ai/dsh-token-meter'
+
+- id: compact-basic
+  name: '@deepseek-ai/dsh-compact-basic'
+
+- id: subagent
+  name: '@deepseek-ai/dsh-subagent'
+
+- id: subagent-spawn
+  name: '@deepseek-ai/dsh-subagent-spawn'
+  config:
+    providerName: spawn
+
+- id: subagent-fork
+  name: '@deepseek-ai/dsh-subagent-fork'
+  config:
+    providerName: fork
+
+- id: tool-subagent
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: spawn
+    toolName: subagent
+
+- id: tool-subagent-fork
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: fork
+    toolName: subagent_fork
+
+- id: workflow-workerthread
+  name: '@deepseek-ai/dsh-workflow-workerthread'
+  config:
+    provider: spawn
+
+- id: tool-workflow
+  name: '@deepseek-ai/dsh-tool-workflow'
+
+- id: timeout-policy
+  name: '@deepseek-ai/dsh-timeout-policy'
+
+- id: spill-local
+  name: '@deepseek-ai/dsh-spill-local'
+
+# Omitting maxInlineBytes makes the whole policy a silent no-op — always
+# state it explicitly.
+- id: spill-policy
+  name: '@deepseek-ai/dsh-spill-policy'
+  config:
+    maxInlineBytes: 50000
+
+# The API gateway: the transport-agnostic dispatch face every client shape
+# shares. provider/model are the host default routing — the profile json's
+# mapping target (user config overrides these engineering defaults).
+- id: api-gateway
+  name: '@deepseek-ai/dsh-host-apiproxy'
+  config:
+    provider: deepseek
+    model: deepseek-v4-flash
+
+# ── layer 2: transport/service ──────────────────────────────────────────────
+
+# Plain route-registration carrier. distIndex is an assembly fact, not user
+# config — AppCLIEntry resolves the frontend dist and patches it in; host and
+# port arrive as CLI-flag patches over these defaults.
+- id: webserver
+  name: '@deepseek-ai/dsh-host-webserver'
+  config:
+    host: 127.0.0.1
+    port: 3080
+
+# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
+
+# Dual-face: node half scans this very tree for dshClient rows, composes
+# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
+# module table the shell kernel constructs before cordis exists (§4.7 —
+# adopted as a plugin entry by the kernel, never fetched).
+- id: modules
+  name: '@deepseek-ai/dsh-client-modules'
+
+# Owns both ends of the web transport: node half binds the gateway to the
+# webserver under /api; browser half is the fetch/SSE client.
+- id: connection
+  name: '@deepseek-ai/dsh-client-connection'
+
+- id: client-runtime
+  name: '@deepseek-ai/dsh-client-runtime'
+
+- id: ui-theme
+  name: '@deepseek-ai/dsh-client-ui-theme'
+
+- id: i18n
+  name: '@deepseek-ai/dsh-client-i18n'
+
+- id: ui-layout
+  name: '@deepseek-ai/dsh-client-ui-layout'
+
+- id: ui-sidebar
+  name: '@deepseek-ai/dsh-client-ui-sidebar'
+
+- id: ui-conversation
+  name: '@deepseek-ai/dsh-client-ui-conversation'
+
+- id: ui-question
+  name: '@deepseek-ai/dsh-client-ui-question'
+
+- id: ui-trajectory
+  name: '@deepseek-ai/dsh-client-ui-trajectory'

+ 44 - 1
apps/cli/package.json

@@ -9,14 +9,22 @@
   },
   "files": [
     "lib/bin.js",
+    "cordis.yml",
     "src"
   ],
   "license": "BSD-3-Clause",
   "dependencies": {
+    "@cordisjs/plugin-include": "workspace:*",
+    "@cordisjs/plugin-loader": "workspace:*",
+    "@cordisjs/plugin-timer": "workspace:*",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-bash-local": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-hmr": "workspace:^",
     "@deepseek-ai/dsh-client-i18n": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -24,13 +32,48 @@
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
+    "@deepseek-ai/dsh-compact-basic": "workspace:^",
     "@deepseek-ai/dsh-frontend": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
+    "@deepseek-ai/dsh-fs-policy": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-host-runtime": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
     "@deepseek-ai/dsh-paths": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
+    "@deepseek-ai/dsh-skill": "workspace:^",
+    "@deepseek-ai/dsh-skill-local": "workspace:^",
+    "@deepseek-ai/dsh-spill-local": "workspace:^",
+    "@deepseek-ai/dsh-spill-policy": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-subagent-fork": "workspace:^",
+    "@deepseek-ai/dsh-subagent-spawn": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tasks": "workspace:^",
+    "@deepseek-ai/dsh-timeout-policy": "workspace:^",
+    "@deepseek-ai/dsh-token-meter": "workspace:^",
+    "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tool-fs": "workspace:^",
+    "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
+    "@deepseek-ai/dsh-tool-skill": "workspace:^",
+    "@deepseek-ai/dsh-tool-subagent": "workspace:^",
+    "@deepseek-ai/dsh-tool-tasks": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
+    "@deepseek-ai/dsh-tool-workflow": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tui": "workspace:^",
-    "cordis": "^4.0.0-rc.7"
+    "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
+    "@deepseek-ai/dsh-workspace-context": "workspace:^",
+    "cordis": "^4.0.0-rc.7",
+    "js-yaml": "^4.2.0"
+  },
+  "devDependencies": {
+    "@types/js-yaml": "^4.0.9"
   }
 }

+ 231 - 0
apps/cli/src/app-cli-entry.ts

@@ -0,0 +1,231 @@
+/**
+ * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares
+ * (config-tree boot wired for `dsh web` this round; TUI/headless migrate
+ * later). Everything here is what must exist before the Loader runs: layered
+ * env, the patch composition over the shipped cordis.yml (profile json + CLI
+ * flags + the resolved frontend dist), and the fail-loud triple after the
+ * tree settles.
+ */
+
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import type { FiberState } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include, { type PatchOptions } from '@cordisjs/plugin-include'
+import yaml from 'js-yaml'
+import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
+import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+// Empty type import carries the httpServer Context merge for the port read below.
+import type {} from '@deepseek-ai/dsh-host-webserver'
+
+/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
+const PROFILE_DIR = '.dsh-tmp-profile'
+const PROFILE_FILE = 'config.json'
+
+/** One profile-json key mapped onto a yml row's config field. */
+interface ProfileMapping {
+  jsonPath: string
+  entryId: string
+  configKey: string
+}
+
+/**
+ * The static profile→row mapping table. json is user config and wins over the
+ * yml engineering default per field; a json key absent from this table fails
+ * loud (a typo silently ignored would read as "setting has no effect").
+ * Developers extend deployments by adding rows here.
+ */
+const PROFILE_MAPPINGS: ProfileMapping[] = [
+  { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
+  { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
+  { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
+]
+
+// The include's YAML dialect: `!!js` scalars become expression nodes the
+// Loader evaluates at entry activation. The bypass parse below must accept
+// them (and passing one through a patch unchanged is legal).
+const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
+  kind: 'scalar',
+  resolve: data => typeof data === 'string',
+  construct: data => ({ __jsExpr: String(data) }),
+})
+const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
+
+/**
+ * Value mirror of cordis's `FiberState` const enum members the sweep needs
+ * (a const enum has no runtime object to import; same rationale as the
+ * client-side mirror in dsh-client-web).
+ */
+const FIBER_ACTIVE = 2 as FiberState.ACTIVE
+const FIBER_PENDING = 0 as FiberState.PENDING
+
+/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */
+export interface AppCLIEntryOptions {
+  /** Absolute path of the shipped cordis.yml. */
+  configPath: string
+  /** Whether to append the HMR row (the whole prod/dev difference). */
+  dev: boolean
+  /** --host when explicitly passed; undefined keeps the yml engineering default. */
+  host?: string
+  /** --port when explicitly passed; undefined keeps the yml engineering default. */
+  port?: number
+}
+
+/**
+ * Boot driver for the config-tree `dsh web` shape: holds only what exists
+ * independently of (and prior to) cordis — argv facts, the composed patch
+ * set, and finally the root ctx.
+ */
+export class AppCLIEntry {
+  /** The root context, set by {@link run}. */
+  ctx!: Context
+
+  private patches: PatchOptions[] = []
+
+  constructor(private readonly options: AppCLIEntryOptions) {}
+
+  /**
+   * Run the boot chain: layered env → patch composition → Loader include
+   * boot (dev row before await) → fail-loud triple.
+   * @returns the settled root context and the listening port.
+   */
+  async run(): Promise<{ ctx: Context; port: number }> {
+    this.loadEnvLayers()
+    this.composePatches()
+    await this.bootTree()
+    this.assertBoot()
+    const port = this.ctx.get('httpServer')?.port
+    /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
+    if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot')
+    return { ctx: this.ctx, port }
+  }
+
+  /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
+  private loadEnvLayers(): void {
+    loadEnv('dsh web', resolveDshHome())
+  }
+
+  /**
+   * Compose the patch set from the three non-yml config sources: profile
+   * json (user config), CLI flags, and the resolved frontend dist. Patches
+   * replace a row's config wholesale, so each patched row's yml static
+   * values are re-read here (bypass parse) and merged under the overrides.
+   */
+  private composePatches(): void {
+    const rows = this.parseYmlRows()
+    const overrides = new Map<string, Record<string, unknown>>()
+    const put = (entryId: string, key: string, value: unknown): void => {
+      const bag = overrides.get(entryId) ?? {}
+      bag[key] = value
+      overrides.set(entryId, bag)
+    }
+
+    // Source 1: profile json (missing file = empty; unmapped key = loud).
+    for (const [key, value] of Object.entries(this.readProfile())) {
+      const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
+      if (mapping === undefined) {
+        throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
+      }
+      put(mapping.entryId, mapping.configKey, value)
+    }
+
+    // Source 2: CLI flags (field set disjoint from the json mappings).
+    if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
+    if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
+
+    // Source 3: the frontend dist — an assembly fact of this app, never yml
+    // user config. Workspace knowledge stays here.
+    put('webserver', 'distIndex', this.resolveDistIndex())
+
+    this.patches = [...overrides.entries()].map(([id, bag]) => {
+      const yml = rows.get(id)
+      if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`)
+      return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
+    })
+  }
+
+  /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
+  private async bootTree(): Promise<void> {
+    const ctx = new Context()
+    ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    await ctx.loader.create({
+      name: 'cordis:include',
+      config: {
+        path: pathToFileURL(resolve(this.options.configPath)).href,
+        ...this.patches.length > 0 ? { patches: this.patches } : {},
+      },
+    })
+    if (this.options.dev) {
+      await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
+    }
+    this.ctx = ctx
+    await ctx.loader.await()
+  }
+
+  /**
+   * Fail-loud triple: assertEntriesLoaded catches import failures,
+   * installFailLoud catches late apply rejections, and the all-ACTIVE sweep
+   * below catches PENDING fibers (cordis inject waiting has no timeout).
+   */
+  private assertBoot(): void {
+    installFailLoud('dsh web')
+    assertEntriesLoaded(this.ctx, 'dsh web')
+    const failures: string[] = []
+    for (const entry of this.ctx.loader.entries()) {
+      if (entry.fiber === undefined || entry.disabled) continue
+      const state = entry.fiber.state
+      if (state === FIBER_ACTIVE) continue
+      if (state === FIBER_PENDING) {
+        const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
+        failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
+      } else {
+        failures.push(`${entry.options.name}: fiber state ${String(state)}`)
+      }
+    }
+    if (failures.length > 0) {
+      throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+    }
+  }
+
+  /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
+  private parseYmlRows(): Map<string, { config?: unknown }> {
+    const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
+    if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`)
+    const rows = new Map<string, { config?: unknown }>()
+    for (const row of doc as { id?: string; config?: unknown }[]) {
+      if (typeof row.id === 'string') rows.set(row.id, row)
+    }
+    return rows
+  }
+
+  /** Profile json under cwd; read-only — never created here, absent = no user config. */
+  private readProfile(): Record<string, unknown> {
+    let raw: string
+    try {
+      raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
+      throw error
+    }
+    const parsed: unknown = JSON.parse(raw)
+    if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+      throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
+    }
+    return parsed as Record<string, unknown>
+  }
+
+  /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
+  private resolveDistIndex(): string {
+    const require = createRequire(import.meta.url)
+    try {
+      return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
+    } catch {
+      throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
+    }
+  }
+}

+ 29 - 123
apps/cli/src/web.ts

@@ -1,160 +1,66 @@
 /**
- * `dsh web` — the web-shape assembly: startHost + dist resolution +
- * startWebServer + the URL line + signal wiring. Mixing host and carrier
- * concerns is this app module's job (packages stay single-sided).
+ * `dsh web` — thin bin over the config-tree boot: parse argv, run
+ * AppCLIEntry, print the URL line, wire signals. All composition lives in
+ * cordis.yml; all boot glue lives in AppCLIEntry.
  */
 
 import { parseArgs } from 'node:util'
 import { networkInterfaces } from 'node:os'
-import { createRequire } from 'node:module'
-import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
-import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
+import { fileURLToPath } from 'node:url'
+import { AppCLIEntry } from './app-cli-entry.ts'
 
 const LOOPBACK_HOST = '127.0.0.1'
 const ALL_INTERFACES_HOST = '0.0.0.0'
 
-// --- Client composition (composition decisions live in the composing app) ---
-// The composition layer owns one decision: which plugin packages mount (the
-// roster). Dependency edges and the boot prefetch tier live in each package's
-// dshClient declaration.
-
-/**
- * Dev-only plugin: the client HMR driver. Whether it composes in is a
- * deployment decision — the dev graph includes its row, the prod graph does
- * not mount it at all.
- */
-const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
-
-/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
-const CLIENT_BUNDLE_POLL_MS = 500
-
-/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
-const CLIENT_PACKAGES = [
-  '@deepseek-ai/dsh-client-connection',
-  '@deepseek-ai/dsh-client-runtime',
-  '@deepseek-ai/dsh-client-ui-theme',
-  '@deepseek-ai/dsh-client-i18n',
-  '@deepseek-ai/dsh-client-ui-layout',
-  '@deepseek-ai/dsh-client-ui-sidebar',
-  '@deepseek-ai/dsh-client-ui-conversation',
-  '@deepseek-ai/dsh-client-ui-question',
-  '@deepseek-ai/dsh-client-ui-trajectory',
-] as const
+const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 
 export async function runWeb(argv: string[]): Promise<void> {
   const { values } = parseArgs({
     args: argv,
     options: {
-      host: { type: 'string', default: LOOPBACK_HOST },
-      port: { type: 'string', default: '3080' },
+      host: { type: 'string' },
+      port: { type: 'string' },
       dev: { type: 'boolean', default: false },
     },
     allowPositionals: false,
   })
-  if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
+  if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
     process.stderr.write(
       `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
     )
     process.exit(1)
   }
-  const hostAddress = values.host
-  const port = Number(values.port)
-  if (!Number.isInteger(port) || port < 0 || port > 65535) {
-    process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
-    process.exit(1)
+  let port: number | undefined
+  if (values.port !== undefined) {
+    port = Number(values.port)
+    if (!Number.isInteger(port) || port < 0 || port > 65535) {
+      process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
+      process.exit(1)
+    }
   }
 
-  // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
-  const host = await startHost({
-    boot: {
-      persistenceRoot: './.sessions',
-      workspaceContext: { maxBytes: 65_536 },
-      sessionTitleLlm: true,
-    },
-  })
-
-  // Client plugin chain: in-memory Loader tree over the composed roster, then
-  // the registry that feeds the __DSH_BOOT__ entry graph and
-  // /plugins/<id>/client.js. All row content comes from dshClient discovery
-  // over the mounted roster (dev adds the HMR driver row and turns on the
-  // bundle watch that drives rebuilt frames).
-  const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
-  const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
-  const webPlugins = createHostWebPluginRegistry({
-    ctx: host.ctx,
-    loader: mounted.loader,
-    resolvePkgJson: mounted.resolvePkgJson,
-    onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
-    ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
+  const entry = new AppCLIEntry({
+    configPath: CONFIG_PATH,
+    dev: values.dev,
+    ...values.host !== undefined ? { host: values.host } : {},
+    ...port !== undefined ? { port } : {},
   })
-  if (values.dev) {
-    // Dev visibility (the registry is a library and never prints): list what
-    // the bundle watch covers, then log every observed rebuild. This is a
-    // second onRebuilt subscription — the SSE relay inside the webserver is
-    // unaffected (multicast).
-    const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
-    const bundlePaths = [...revs.keys()]
-      .map(id => webPlugins.clientPath(id))
-      .filter((path): path is string => path !== undefined)
-    console.log(
-      `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n  ${bundlePaths.join('\n  ')}`,
-    )
-    webPlugins.onRebuilt((id, rev) => {
-      console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
-      revs.set(id, rev)
-    })
-  }
-  // Published so the webserver invariant companion can audit manifest/bundle
-  // consistency; nothing else reads this key.
-  host.ctx.reflect.provide('webPlugins', webPlugins)
-
-  // Dist location is workspace knowledge of this app: resolved through
-  // @deepseek-ai/dsh-frontend's package exports, not configured.
-  const require = createRequire(import.meta.url)
-  let distIndex: string
-  try {
-    distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
-  } catch {
-    process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
-    await host.dispose()
-    process.exit(1)
-  }
+  const { ctx, port: boundPort } = await entry.run()
 
   let exiting = false
-  async function shutdown(code: number): Promise<void> {
+  const shutdown = (code: number): void => {
     if (exiting) return
     exiting = true
-    try {
-      await server.close()
-      await host.dispose()
-    } finally {
-      process.exit(code)
-    }
-  }
-
-  let server: Awaited<ReturnType<typeof startWebServer>>
-  try {
-    server = await startWebServer(
-      { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins },
-      (err: Error) => {
-        process.stderr.write(`dsh web: ${String(err)}\n`)
-        void shutdown(1)
-      },
-    )
-  } catch (error: unknown) {
-    // listen failed (EADDRINUSE…): no server to close, dispose the host directly.
-    process.stderr.write(`dsh web: ${String(error)}\n`)
-    await host.dispose()
-    process.exit(1)
+    void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
   }
 
-  const lan = hostAddress === ALL_INTERFACES_HOST
+  const lanCandidate = values.host === ALL_INTERFACES_HOST
     ? Object.values(networkInterfaces()).flat()
       .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
     : undefined
-  const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
-  console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
+  const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
+  console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
 
-  process.on('SIGTERM', () => { void shutdown(0) })
-  process.on('SIGINT', () => { void shutdown(130) })
+  process.on('SIGTERM', () => { shutdown(0) })
+  process.on('SIGINT', () => { shutdown(130) })
 }

+ 0 - 1
apps/web/package.json

@@ -24,7 +24,6 @@
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-web-react": "workspace:^",
-    "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@types/node": "^22.0.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 2 - 2
apps/web/src/main.ts

@@ -3,8 +3,8 @@
  * loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
  * in @deepseek-ai/dsh-client-web; this file only finds the mount point.
  */
-import { bootWebShell } from '@deepseek-ai/dsh-client-web'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 const el = document.getElementById('root')
 if (el === null) throw new Error('web app: missing #root')
-bootWebShell(el)
+void new AppWebEntry(el).run()

+ 5 - 3
apps/web/tests/session-title.snapshot.ts

@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
 import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules'
-import { bootWebShell } from '@deepseek-ai/dsh-client-web'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
@@ -81,13 +81,15 @@ it('projects initial and revised durable titles through the built eight-plugin f
   const root = document.querySelector<HTMLElement>('#root')
   if (root === null) throw new Error('snapshot root missing')
   act(() => {
-    unmount = bootWebShell(root, {
+    const entry = new AppWebEntry(root, {
       fetchBundle: (url) => {
         const code = bundles.get(url)
         return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
       },
       executeBundle: (code) => { (0, eval)(code) },
     })
+    void entry.run()
+    unmount = () => { entry.dispose() }
   })
 
   const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })

+ 0 - 291
apps/web/tests/smoke-fixture.e2e.ts

@@ -1,291 +0,0 @@
-// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
-// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
-// chromium. First describe: graph injection + the fail-loud half. Second
-// describe: the settled success pass — all nine REAL tsdown bundles load
-// through the module system + vendored Loader chain in ?fixture mode (the
-// infrastructure four ride the immediately prefetch tier, the UI rows fetch
-// on demand), the three-column frame appears in one flip, and the resident
-// question completes through the real UI stack. The full model round lands
-// in smoke-real under the W5 real-host standard.
-import { existsSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
-import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
-import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
-import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
-
-const bundlePath = (dir: string): string =>
-  fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
-
-const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
-const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
-
-/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
-const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
-  { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
-  { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
-  { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
-
-const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
-  ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
-
-const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
-  ...(p.inject !== undefined ? { inject: p.inject } : {}),
-  ...(p.immediately === true ? { immediately: true } : {}),
-}))
-
-/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
-const FAIL_GRAPH: WebBootGraph = {
-  rev: 'e2e-fail',
-  entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
-}
-
-/** Graph for the success pass: the complete assembly. */
-const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
-
-/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
-function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
-  return {
-    graph: () => graph,
-    clientPath: (id: string) => byId.get(id),
-    onRebuilt: () => () => undefined,
-  }
-}
-
-describe('web boot chain (keyless, real carrier)', () => {
-  let server: Awaited<ReturnType<typeof startWebServer>>
-  let browser: Browser
-  let page: Page
-  const pageErrors: string[] = []
-
-  beforeAll(async () => {
-    requireDist()
-    const port = await probeFreePort()
-    const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
-    server = await startWebServer({
-      host: '127.0.0.1',
-      port,
-      distIndex: DIST_INDEX,
-      apiHandler,
-      webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
-    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
-    browser = await chromium.launch()
-    page = await browser.newPage()
-    page.on('pageerror', e => pageErrors.push(String(e)))
-    await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
-  })
-
-  afterAll(async () => {
-    await browser?.close()
-    await server?.close()
-  })
-
-  it('GET / injects the entry graph verbatim', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
-    const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
-    expect(boot).toEqual(FAIL_GRAPH)
-  })
-
-  it('serves a real bundle through the plugins endpoint', async () => {
-    const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
-    expect(res.status()).toBe(200)
-    expect(await res.text()).toContain('window.__ModuleLoader__.load')
-  })
-
-  it('boots to the loading page and fail-louds the absent entry', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
-    await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
-    await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
-    await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
-    // The real UI must not have flipped in: the gate opens only on settled.
-    expect(await page.locator('[class*="frame"]').count()).toBe(0)
-  })
-
-  it('applies the token sheets before any plugin CSS', async () => {
-    const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
-    expect(family.trim().length).toBeGreaterThan(0)
-  })
-})
-
-describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
-  let server: Awaited<ReturnType<typeof startWebServer>>
-  let browser: Browser
-  let page: Page
-  const pageErrors: string[] = []
-
-  beforeAll(async () => {
-    requireDist()
-    const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
-    if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
-    const port = await probeFreePort()
-    // ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
-    const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
-    server = await startWebServer({
-      host: '127.0.0.1',
-      port,
-      distIndex: DIST_INDEX,
-      apiHandler,
-      webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
-    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
-    browser = await chromium.launch()
-    page = await browser.newPage()
-    page.on('pageerror', e => pageErrors.push(String(e)))
-    await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
-  })
-
-  afterAll(async () => {
-    await browser?.close()
-    await server?.close()
-  })
-
-  it('settles and flips to the three-column frame in one pass', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
-    await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
-    // Loading page is gone; the grid carries the three tracks.
-    expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
-    const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
-    expect(template.split(' ').length).toBe(3)
-  })
-
-  it('every plugin CSS landed with its ownership tag', async () => {
-    const owners = await page.evaluate(() =>
-      [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
-    expect(owners).toContain(LAYOUT_ID)
-    expect(owners).toContain(SIDEBAR_ID)
-  })
-
-  it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
-    const frame = page.locator('[class*="frame"]')
-    const firstTrack = async (): Promise<string> => (await frame.evaluate(
-      el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
-    // The tracks transition on the deepsuite curve; assert the animated
-    // settle rather than an instant jump.
-    const settledTrack = async (px: string): Promise<void> => {
-      await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
-    }
-    // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
-    const brand = () => page.locator('[class*="brand"]').count()
-    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
-    // Mid-collapse the wide chrome is still mounted, fading — not swapped out.
-    expect(await brand()).toBe(1)
-    await settledTrack('56px')
-    await expect.poll(brand, { timeout: 2000 }).toBe(0)
-    for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
-      await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
-    }
-    await page.getByRole('button', { name: 'Open sidebar' }).click()
-    await settledTrack('280px')
-    await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
-    // Rail search: collapse again, the search control expands and lands in the box.
-    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
-    await settledTrack('56px')
-    await page.getByRole('button', { name: 'Search sessions' }).click()
-    await settledTrack('280px')
-    // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
-    await expect.poll(() => page.evaluate(() =>
-      (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
-  })
-
-  it('renders file tool rows and expands fixture reasoning from either click target', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
-    await page.locator('[role="treeitem"]').first().click()
-    await page.locator('[role="treeitem"][aria-selected]').first().click()
-
-    const thinkRoot = page.locator('[data-variant="think"]').first()
-    const think = thinkRoot.getByRole('button')
-    await think.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await think.getAttribute('aria-expanded')).toBe('false')
-
-    await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
-    expect(await think.getAttribute('aria-expanded')).toBe('true')
-    expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
-
-    await think.getByText('Think', { exact: true }).click()
-    expect(await think.getAttribute('aria-expanded')).toBe('false')
-
-    const editRoot = page.locator('[data-variant="edit"]').first()
-    await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
-    expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
-
-    const writeRoot = page.locator('[data-variant="write"]').first()
-    await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
-    expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
-  })
-
-  it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
-    await page.getByRole('button', { name: 'New session', exact: true }).click()
-    const input = page.locator('textarea[placeholder]')
-    await input.waitFor({ timeout: 15_000 })
-    await input.fill('render markdown')
-    await page.getByRole('button', { name: '发送' }).click()
-
-    const streaming = page.locator('[data-streaming="true"]')
-    await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
-    await streaming.waitFor({ state: 'detached', timeout: 15_000 })
-
-    const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
-    expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
-    expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
-    const external = page.getByRole('link', { name: 'DeepSeek' })
-    expect(await external.getAttribute('target')).toBe('_blank')
-    expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
-  })
-
-  it('renders and completes the resident question through the composer slot', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
-    const sessionTree = page.getByRole('tree', { name: 'Sessions' })
-    const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
-    if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
-    await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
-    const composer = page.locator('[data-question-key]')
-    await composer.waitFor({ timeout: 15_000 })
-    expect({
-      question: await composer.getByRole('heading').innerText(),
-      progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
-      options: await composer.getByRole('radio').allTextContents(),
-      custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
-    }).toMatchInlineSnapshot(`
-      {
-        "custom": "其他,请填写自定义答案",
-        "options": [
-          "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
-          "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
-          "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
-        ],
-        "progress": "1 / 3",
-        "question": "你现在更想招哪类 Agent/Harness 候选人?",
-      }
-    `)
-
-    await composer.getByRole('radio', { name: '工程落地型' }).click()
-    await composer.getByText('2 / 3', { exact: true }).waitFor()
-    await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
-    await composer.getByRole('checkbox', { name: '系统设计' }).click()
-    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
-    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
-
-    await composer.waitFor({ state: 'detached' })
-    const restoredInput = page.locator('textarea[placeholder]')
-    await restoredInput.waitFor()
-    expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
-  })
-
-  it('stayed clean: no page errors across the whole load chain', () => {
-    expect(pageErrors).toEqual([])
-  })
-})

+ 1 - 4
apps/web/tests/support.ts

@@ -16,10 +16,7 @@ export function requireDist(): void {
   }
 }
 
-/**
- * OS-assigned free port, released before use. startWebServer echoes
- * options.port instead of the bound one, so passing 0 directly is unusable.
- */
+/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
 export function probeFreePort(): Promise<number> {
   return new Promise((resolvePort, reject) => {
     const probe = createServer()

+ 0 - 3
apps/web/tsconfig.json

@@ -21,9 +21,6 @@
     {
       "path": "../../packages/client/web"
     },
-    {
-      "path": "../../packages/host/webserver"
-    },
     {
       "path": "../../packages/client/modules"
     }

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

@@ -22,7 +22,7 @@ export default defineConfig({
       { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
-      { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
     ],
   },
   define: {

+ 14 - 0
docs/capability-seams.md

@@ -125,6 +125,12 @@ flowchart LR
   svc_spillStore["ctx.spillStore<br/>Spill storage seam"]
   pkg_spill_local["spill-local"]
   pkg_spill_policy["spill-policy"]
+  pkg_webserver["webserver"]
+  svc_httpServer["ctx.httpServer<br/>HTTP route registration"]
+  pkg_connection["connection"]
+  pkg_modules["modules"]
+  pkg_hmr["hmr"]
+  svc_clientModuleHost["ctx.clientModuleHost<br/>Client plugin graph host"]
   pkg_workflow["workflow"]
   svc_workflows["ctx.workflows<br/>Workflow script engine"]
   pkg_workflow_workerthread["workflow-workerthread"]
@@ -152,6 +158,7 @@ flowchart LR
   pkg_llm_deepseek --> svc_llm
   pkg_llm_pi_ai --> svc_llm
   pkg_llm_replay --> svc_llm
+  pkg_modules --> svc_clientModuleHost
   pkg_permission --> svc_permission
   pkg_plan_mode --> svc_planMode
   pkg_pty --> svc_pty
@@ -193,6 +200,7 @@ flowchart LR
   pkg_web_search_deepseek --> svc_web
   pkg_web_search_exa --> svc_web
   pkg_web_search_perplexity --> svc_web
+  pkg_webserver --> svc_httpServer
   pkg_workflow --> svc_workflows
   pkg_workflow_workerthread --> svc_workflows
   pkg_workspace --> svc_workspace
@@ -207,11 +215,15 @@ flowchart LR
   svc_bash --> pkg_hooks_claude
   svc_bash --> pkg_hooks_codex
   svc_bash --> pkg_tool_bash
+  svc_clientModuleHost --> pkg_hmr
   svc_codeRuntime --> pkg_tools
   svc_commands --> pkg_acp
   svc_commands --> pkg_tui
   svc_compact --> pkg_compact_basic
   svc_fs --> pkg_tool_fs
+  svc_httpServer --> pkg_connection
+  svc_httpServer --> pkg_hmr
+  svc_httpServer --> pkg_modules
   svc_invariants --> pkg_agent
   svc_invariants --> pkg_agent_loop
   svc_invariants --> pkg_scope
@@ -318,6 +330,8 @@ flowchart LR
 | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
 | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
 | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
+| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
+| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. |
 | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. |
 
 Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.

+ 48 - 5
docs/config-catalog.md

@@ -276,6 +276,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) ·
 
 Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts)
 
+## `@deepseek-ai/dsh-client-hmr`
+
+Requires: `clientModuleHost` · `httpServer`
+
+```ts config-catalog
+/** Plugin config, validated by the same-named schemastery schema. */
+export interface Config {
+  /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
+  pollIntervalMs?: number
+}
+```
+
+Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
+
 ## `@deepseek-ai/dsh-code-runtime-worker`
 
 ```ts config-catalog
@@ -474,6 +488,38 @@ export interface Config {
 
 Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
 
+## `@deepseek-ai/dsh-host-apiproxy`
+
+Requires: `agents` · `sessions` · `tools` · `userInteraction`
+
+```ts config-catalog
+/** Gateway plugin config: the host-level default agent routing. */
+export interface Config {
+  /** Default provider route for created/resumed agents. */
+  provider: string
+  /** Default model id. */
+  model: string
+}
+```
+
+Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts)
+
+## `@deepseek-ai/dsh-host-webserver`
+
+```ts config-catalog
+/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
+export interface Config {
+  /** Listen host; the two supported values are loopback and all-interfaces. */
+  host: '127.0.0.1' | '0.0.0.0'
+  /** Listen port; zero requests an OS-assigned port. */
+  port: number
+  /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
+  distIndex: string
+}
+```
+
+Source: [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts)
+
 ## `@deepseek-ai/dsh-invariants`
 
 ```ts config-catalog
@@ -1971,9 +2017,9 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont
 These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
 
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
-- `@deepseek-ai/dsh-client-connection` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
-- `@deepseek-ai/dsh-client-hmr` ([`packages/client/hmr/src/index.ts`](../packages/client/hmr/src/index.ts))
+- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
 - `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts))
+- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
@@ -2022,16 +2068,13 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
 - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
-- `@deepseek-ai/dsh-client-modules` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
 - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
 - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
 - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
-- `@deepseek-ai/dsh-host-apiproxy` ([`packages/host/apiproxy/src/index.ts`](../packages/host/apiproxy/src/index.ts))
 - `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
-- `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts))
 - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))

+ 68 - 0
docs/cordis-catalog/services.md

@@ -319,6 +319,50 @@ Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../c
 
 Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts)
 
+## `ctx.clientModuleHost` — `ClientModuleHostService`
+
+The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
+
+```ts cordis-catalog
+/**
+ * Current composed entry graph (stable object between changes).
+ * @returns the graph served as `window.__DSH_BOOT__`.
+ */
+graph(): WebBootGraph
+
+/**
+ * Absolute path of an entry's client bundle.
+ * @param id - entry id (package name).
+ * @returns the path, or undefined for an unknown id.
+ */
+clientPath(id: string): string | undefined
+
+/**
+ * Re-hash one bundle (the HMR watch's registration hook — the only entry
+ * point through which bundle content changes reach the graph).
+ * @param id - entry id (package name).
+ * @returns the new rev, or undefined for an unknown id.
+ */
+rebuilt(id: string): string | undefined
+
+/**
+ * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
+ * @param listener - receives the entry id and its new bundle rev.
+ * @returns the unsubscriber.
+ */
+onRebuilt(listener: (id: string, rev: string) => void): () => void
+
+/**
+ * Fires after any flush that recomposed the graph (row added/removed, or a
+ * rebuilt rev change). Pull model: listeners re-read {@link graph}.
+ * @param listener - notified with no payload.
+ * @returns the unsubscriber.
+ */
+onGraphChanged(listener: () => void): () => void
+```
+
+Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
+
 ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
 
 Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
@@ -614,6 +658,30 @@ Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-d
 
 Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts)
 
+## `ctx.httpServer` — `HttpServerService`
+
+The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports.
+
+```ts cordis-catalog
+/**
+ * Register a named route. Duplicate (kind, path) throws — route patterns are
+ * a composition-level contract, so a collision is a misconfiguration.
+ * @param route - kind, path, and the owning handler.
+ * @returns the disposer removing the route.
+ */
+register(route: WebRoute): () => void
+
+/**
+ * Register an index.html transform, applied to every index response in
+ * registration order.
+ * @param transform - pure html-to-html function.
+ * @returns the disposer removing the transform.
+ */
+tapIndex(transform: (html: string) => string): () => void
+```
+
+Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts)
+
 ## `ctx.invariants` — `InvariantService`
 
 Package-owned invariant registry with global and regex-based selection.

+ 6 - 6
docs/event-producer-consumer.md

@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
 | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
 | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
@@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
 | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
 | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
@@ -34,9 +34,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -61,7 +61,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `webserver` |
+| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `slots/changed` | `runtime` (`emit`) | - |
 

+ 5 - 3
docs/module-graph.md

@@ -223,7 +223,6 @@ flowchart TD
   pkg_subagent_subprocess --> pkg_invariants
   pkg_acp_snapshot --> pkg_invariants
   pkg_loader_smoke --> pkg_invariants
-  pkg_client_connection --> pkg_invariants
   pkg_client_i18n --> pkg_invariants
   pkg_client_modules --> pkg_invariants
   pkg_client_runtime --> pkg_invariants
@@ -242,7 +241,10 @@ flowchart TD
   pkg_storage --> pkg_invariants
   pkg_llm --> pkg_brand
   pkg_llm --> pkg_invariants
+  pkg_client_connection --> pkg_host_webserver
+  pkg_client_connection --> pkg_invariants
   pkg_client_hmr --> pkg_client_modules
+  pkg_client_hmr --> pkg_host_webserver
   pkg_client_hmr --> pkg_invariants
   pkg_client_ui_conversation --> pkg_client_runtime
   pkg_client_ui_conversation --> pkg_client_ui_primitives
@@ -811,7 +813,6 @@ flowchart TD
 | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) |
 | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) |
 | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) |
-| [`client-connection`](../packages/client/connection) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
@@ -829,7 +830,8 @@ flowchart TD
 | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
 | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
 | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
-| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) |
+| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
+| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |

+ 3 - 6
knip.json

@@ -56,12 +56,8 @@
       ]
     },
     "packages/host/webserver": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
       "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
+        "src/**/*.ts"
       ]
     },
     "packages/host/runtime": {
@@ -579,7 +575,8 @@
         "src/**/*.ts"
       ],
       "ignoreDependencies": [
-        "@deepseek-ai/dsh-client-.+"
+        "@deepseek-ai/.+",
+        "@cordisjs/.+"
       ]
     },
     "packages/client/modules": {

+ 2 - 0
packages/client/connection/package.json

@@ -43,10 +43,12 @@
     "src"
   ],
   "peerDependencies": {
+    "@deepseek-ai/dsh-host-webserver": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }

+ 8 - 0
packages/client/connection/src/api-path.ts

@@ -0,0 +1,8 @@
+/**
+ * The /api URL prefix — single source for both halves of the web transport.
+ * The node half registers this prefix on the web server; browser-side path
+ * literals currently live in the apiproxy client layer (out of scope here).
+ */
+
+/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
+export const API_PATH = '/api'

+ 59 - 0
packages/client/connection/src/http-bridge.ts

@@ -0,0 +1,59 @@
+/**
+ * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
+ * web carrier; the fetch-shaped handler itself is transport-agnostic).
+ */
+
+import type { IncomingMessage, ServerResponse } from 'node:http'
+
+/**
+ * Bridge one node:http request to the fetch-shaped handler (client close
+ * aborts; SSE bodies stream out chunk by chunk).
+ * @param req - incoming node:http request (fully read before dispatch).
+ * @param res - node:http response the bridge writes and owns to completion.
+ * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
+ */
+export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
+  const abort = new AbortController()
+  // Client-disconnect detection MUST hang off the response, not the request:
+  // since Node 16, IncomingMessage 'close' fires as soon as the request body is
+  // fully consumed (immediately for a bodyless GET), which would abort every SSE
+  // stream right after open. ServerResponse 'close' fires on connection teardown;
+  // writableEnded distinguishes a normal end() from the client going away.
+  res.on('close', () => {
+    if (!res.writableEnded) abort.abort()
+  })
+  const chunks: Buffer[] = []
+  for await (const chunk of req) chunks.push(chunk as Buffer)
+  /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
+  requests; the fields are only optional on the client-side IncomingMessage type */
+  const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
+    method: req.method ?? 'GET',
+    headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
+    ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
+    signal: abort.signal,
+  })
+  const response = await apiHandler.fetch(request)
+  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
+  if (response.body === null) {
+    res.end()
+    return
+  }
+  for await (const chunk of response.body) {
+    // Backpressure: a false return means the socket buffer is full — wait for drain
+    // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
+    // resolves so a mid-wait disconnect can't park this loop forever; the close
+    // handler above aborts the handler stream, which then ends the iteration.
+    if (!res.write(chunk)) {
+      await new Promise<void>((resolve) => {
+        const done = (): void => {
+          res.off('drain', done)
+          res.off('close', done)
+          resolve()
+        }
+        res.once('drain', done)
+        res.once('close', done)
+      })
+    }
+  }
+  res.end()
+}

+ 33 - 7
packages/client/connection/src/index.ts

@@ -1,10 +1,36 @@
 /**
- * Connection plugin, node half. The package IS a dshClient plugin: the wire
- * consumer layer lives in its client half in full (src/client/ — contract:
- * api-contracts v3 section 3, inventory §3.2); consumers import the /client
- * subpath. The empty apply exists so the plugin appears in the host Loader
- * (lifecycle governance + dshClient discovery).
+ * Connection plugin, node half: the host end of the web transport. Registers
+ * the /api prefix route on the web server and bridges node:http requests to
+ * the transport-agnostic fetch-shaped api handler. The wire consumer layer
+ * lives in the client half (src/client/ — contract: api-contracts v3
+ * section 3); consumers import the /client subpath.
  */
+import type { Context } from 'cordis'
+// Type-only route import; it also carries the httpServer Context merge.
+import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
+import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
+import { API_PATH } from './api-path.ts'
+import { bridge } from './http-bridge.ts'
 
-/** Host plugin body — no host-side behavior for the connection plugin. */
-export function apply(_ctx: unknown): void {}
+export { API_PATH } from './api-path.ts'
+
+/** Cordis plugin name. */
+export const name = 'client-connection'
+
+/** Required services: the route registry and the api gateway. */
+export const inject = ['httpServer', 'apiProxy']
+
+/**
+ * Mount the /api transport: wrap the api gateway into a fetch handler and
+ * serve it under the /api prefix.
+ * @param ctx - host plugin context carrying httpServer and apiProxy.
+ */
+export function apply(ctx: Context): void {
+  const apiHandler = toFetchHandler(ctx.apiProxy)
+  const route: WebRoute = {
+    kind: 'prefix',
+    path: API_PATH,
+    handler: (req, res) => bridge(req, res, apiHandler),
+  }
+  ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
+}

+ 4 - 3
packages/client/connection/src/invariant.ts

@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: the pure wire layer emits no cordis events and owns no
+ * No runtime invariant: the wire layer emits no cordis events and owns no
  * mutable cross-plugin relation — stream/reconnect sequencing is exercised
- * directly by its behavior specs, and rpcId round-trip discipline is owned by
- * the apiproxy contract layer.
+ * directly by its behavior specs, rpcId round-trip discipline is owned by the
+ * apiproxy contract layer, and the node half's single route registration's
+ * register/dispose symmetry is audited by the webserver package's invariant.
  */
 const install: InvariantInstaller = () => {}
 

+ 29 - 6
packages/client/connection/tests/node-half.spec.ts

@@ -1,10 +1,33 @@
-/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
+/** Node half: registers the /api prefix route bridging to the api gateway. */
+import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
-import { apply } from '../src/index.ts'
+import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
+import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
+import { API_PATH, apply, inject } from '../src/index.ts'
 
-describe('node half', () => {
-  it('apply is a no-op host placeholder', () => {
-    apply(undefined)
-    expect(true).toBe(true) // reaching here without throw is the contract
+describe('connection node half', () => {
+  it('registers the /api prefix route and removes it with the fiber', async () => {
+    const ctx = new Context()
+    const routes: WebRoute[] = []
+    // Structural fake: the plugin only touches register(); the service class
+    // carries private state a literal cannot (and need not) reproduce.
+    const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
+      register(route) {
+        routes.push(route)
+        return () => { routes.splice(routes.indexOf(route), 1) }
+      },
+      tapIndex: () => () => {},
+      port: 0,
+    }
+    ctx.provide('httpServer', httpServer as HttpServerService)
+    ctx.provide('apiProxy', {} as unknown as ApiProxy)
+
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(routes).toHaveLength(1)
+    expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
+
+    await fiber.dispose()
+    expect(routes).toHaveLength(0)
   })
 })

+ 5 - 1
packages/client/connection/tsconfig.json

@@ -2,7 +2,8 @@
   "extends": "../../../tsconfig.base.client.json",
   "compilerOptions": {
     "rootDir": "src",
-    "outDir": "lib/types"
+    "outDir": "lib/types",
+    "types": ["node"]
   },
   "include": [
     "src"
@@ -20,6 +21,9 @@
     {
       "path": "../../host/apiproxy"
     },
+    {
+      "path": "../../host/webserver"
+    },
     {
       "path": "../../ui/user-approval"
     },

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

@@ -28,15 +28,20 @@
     "immediately": true
   },
   "license": "BSD-3-Clause",
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
   "peerDependencies": {
     "@cordisjs/plugin-loader": "^1.0.0-rc.5",
     "@deepseek-ai/dsh-client-modules": "^0.0.1",
+    "@deepseek-ai/dsh-host-webserver": "^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-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   },

+ 4 - 13
packages/client/hmr/src/client/index.ts

@@ -64,20 +64,11 @@
  */
 import type { Context } from 'cordis'
 import type { Entry, Loader } from '@cordisjs/plugin-loader'
-import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
+import type { PluginsEventFrame } from '../events.ts'
+import { EVENTS_ENDPOINT } from '../events.ts'
 
-/**
- * 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'
+export type { PluginsEventFrame } from '../events.ts'
+export { EVENTS_ENDPOINT } from '../events.ts'
 
 /** Cordis plugin name. */
 export const name = 'client-hmr'

+ 16 - 0
packages/client/hmr/src/events.ts

@@ -0,0 +1,16 @@
+/**
+ * Wire protocol of the `/plugins/events` dev SSE channel — single source for
+ * both halves of this package. Frames still cross a wire boundary: the
+ * browser half validates them at its JSON parse point; sharing the type keeps
+ * the two ends from drifting, not from parsing.
+ */
+
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
+
+/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
+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'

+ 149 - 6
packages/client/hmr/src/index.ts

@@ -1,9 +1,152 @@
 /**
- * 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).
+ * HMR plugin, node half: the host end of the dev reload chain. Stat-polls
+ * every graph row's client bundle (fs.watchFile — polling by design: network
+ * mounts deliver no inotify events), reports content changes through
+ * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
+ * broadcasting graph/rebuilt frames to the browser half (src/client/).
+ * Dev-only row: prod compositions never mount this plugin.
  */
+import type { Stats } from 'node:fs'
+import { unwatchFile, watchFile } from 'node:fs'
+import type { ServerResponse } from 'node:http'
+import type { Context } from 'cordis'
+import z from 'schemastery'
+// Empty type imports carry the clientModuleHost/httpServer Context merges.
+import type {} from '@deepseek-ai/dsh-client-modules'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import type { PluginsEventFrame } from './events.ts'
+import { EVENTS_ENDPOINT } from './events.ts'
 
-/** Host plugin body — no host-side behavior for the HMR plugin. */
-export function apply(): void {}
+export type { PluginsEventFrame } from './events.ts'
+export { EVENTS_ENDPOINT } from './events.ts'
+
+/** Cordis plugin name. */
+export const name = 'client-hmr'
+
+/** Required services: the web plugin table and the route registry. */
+export const inject = ['clientModuleHost', 'httpServer']
+
+/** Plugin config, validated by the same-named schemastery schema. */
+export interface Config {
+  /** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
+  pollIntervalMs?: number
+}
+
+export const Config: z<Config> = z.object({
+  pollIntervalMs: z.number().step(1).min(1).default(500),
+})
+
+/** Serialize one frame as an SSE data line. */
+function sseData(frame: PluginsEventFrame): string {
+  return `data: ${JSON.stringify(frame)}\n\n`
+}
+
+/**
+ * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
+ * @param ctx - host plugin context carrying clientModuleHost and httpServer.
+ * @param config - validated {@link Config}.
+ */
+export function apply(ctx: Context, config: Config): void {
+  // schemastery's .default() guarantees the field is set after validation.
+  const pollIntervalMs = config.pollIntervalMs as number
+
+  // --- bundle watch: one fs.watchFile stat poll per graph row -------------
+  const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
+
+  const watchRow = (id: string, path: string): void => {
+    const listener = (curr: Stats, prev: Stats): void => {
+      // fs.watchFile fires on any stat delta (atime included); only content
+      // signals count. An all-zero curr means the file vanished mid-rebuild
+      // — the completing write fires the next tick, so skipping is safe.
+      if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
+      if (curr.mtimeMs === 0) return
+      try {
+        // rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
+        // fires onRebuilt only on a real rev change). A torn read of a
+        // half-written bundle self-heals on the next poll tick.
+        ctx.clientModuleHost.rebuilt(id)
+      } catch (error) {
+        const code = (error as NodeJS.ErrnoException).code
+        if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
+        ctx.logger.warn(error)
+      }
+    }
+    watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
+    watched.set(id, { path, listener })
+  }
+
+  // Diff the watch set against the current graph: drop watches for removed
+  // rows (or rows whose bundle path moved), add watches for new rows.
+  const syncWatches = (): void => {
+    const rows = new Map<string, string>()
+    for (const row of ctx.clientModuleHost.graph().entries) {
+      const path = ctx.clientModuleHost.clientPath(row.id)
+      if (path !== undefined) rows.set(row.id, path)
+    }
+    for (const [id, watch] of watched) {
+      if (rows.get(id) === watch.path) continue
+      unwatchFile(watch.path, watch.listener)
+      watched.delete(id)
+    }
+    for (const [id, path] of rows) {
+      if (!watched.has(id)) watchRow(id, path)
+    }
+  }
+
+  ctx.effect(() => {
+    // Initial sync covers rows already in the graph; the subscription covers
+    // rows arriving later (boot-window activations, including this plugin's
+    // own row — no self-exemption, a modules/hmr rebuild rides the same chain).
+    syncWatches()
+    const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
+    return () => {
+      unsubscribe()
+      for (const { path, listener } of watched.values()) unwatchFile(path, listener)
+      watched.clear()
+    }
+  }, 'client-hmr: bundle watches')
+
+  // --- /plugins/events SSE channel ----------------------------------------
+  const connections = new Set<ServerResponse>()
+
+  const connect = (res: ServerResponse): void => {
+    res.writeHead(200, {
+      'content-type': 'text/event-stream',
+      'cache-control': 'no-cache',
+      'connection': 'keep-alive',
+    })
+    // Comment line on open so clients/proxies see a live channel even when
+    // no rebuild ever happens; EventSource frame parsing skips it naturally.
+    res.write(': connected\n\n')
+    res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
+    connections.add(res)
+    res.on('close', () => { connections.delete(res) })
+  }
+
+  ctx.effect(() => {
+    const disposeRoute = ctx.httpServer.register({
+      kind: 'exact',
+      path: EVENTS_ENDPOINT,
+      handler: (req, res) => {
+        // Named routes match ahead of the carrier's method gate; keep the old
+        // global 405 semantics for non-GET hits on this endpoint.
+        if (req.method !== 'GET' && req.method !== 'HEAD') {
+          res.writeHead(405)
+          res.end()
+          return
+        }
+        connect(res)
+      },
+    })
+    const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
+      const line = sseData({ type: 'rebuilt', id, rev })
+      for (const res of connections) res.write(line)
+    })
+    return () => {
+      unsubscribe()
+      disposeRoute()
+      for (const res of connections) res.destroy()
+      connections.clear()
+    }
+  }, 'client-hmr: /plugins/events channel')
+}

+ 35 - 9
packages/client/hmr/src/invariant.ts

@@ -3,8 +3,7 @@
  * @module @deepseek-ai/dsh-client-hmr/invariant
  */
 
-/* jscpd:ignore-start */
-import type { Context } from 'cordis'
+import type { Context, Fiber } from 'cordis'
 import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
 
 const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
@@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant'
 /** Service required before the companion can reserve package ownership. */
 export const inject = ['invariants']
 
+/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
+function statWatchers(): number {
+  return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
+}
+
 /**
- * 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.
+ * Owned relation: every bundle stat watcher the node half starts must die
+ * with its fiber — a surviving poller would keep re-hashing bundles for a
+ * torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
+ * count observed at fiber creation must be restored once disposal has drained
+ * the fiber's effects (`internal/plugin` fires at dispose start; the microtask
+ * hop lets the disposer queue its unload before `fiber.await()` joins it).
+ * SSE-connection and listener teardown live inside the same ctx.effect
+ * disposers, so the watcher count is the relation's observable proxy.
  */
-const install: InvariantInstaller = () => {}
+const install: InvariantInstaller = (ctx, fail) => {
+  const baselines = new WeakMap<Fiber, number>()
+  // Async listener by design: emitPluginDisposed awaits-and-logs returned
+  // promises, so a violation surfaces loudly instead of unhandled.
+  // eslint-disable-next-line @typescript-eslint/no-misused-promises
+  ctx.on('internal/plugin', async (fiber) => {
+    if (fiber.name !== 'client-hmr') return
+    if (fiber.uid !== null) {
+      baselines.set(fiber, statWatchers())
+      return
+    }
+    const baseline = baselines.get(fiber)
+    if (baseline === undefined) return
+    await Promise.resolve()
+    await fiber.await()
+    const remaining = statWatchers()
+    if (remaining > baseline) {
+      fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
+    }
+  }, { global: true })
+}
 
 /**
  * Register this package's invariant companion.
@@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {}
  */
 export const apply = (ctx: Context): Promise<() => void> =>
   Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-/* jscpd:ignore-end */

+ 110 - 8
packages/client/hmr/tests/node-half.spec.ts

@@ -1,14 +1,116 @@
 /**
- * Node half of the HMR plugin: an empty apply placeholder (the reload driver
- * lives in the client half) whose only contract is mounting and disposing
- * cleanly in the host Loader.
+ * Node half of the HMR plugin: bundle watches follow the graph, stat changes
+ * report through clientModuleHost.rebuilt, and everything dies with the fiber.
  */
-import { describe, expect, it } from 'vitest'
-import { apply } from '@deepseek-ai/dsh-client-hmr'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
+import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
+import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
+
+const POLL_MS = 20
+
+let dir: string
+
+beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
+afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
+
+/**
+ * Controllable clientModuleHost fake over a mutable id → bundle-path table.
+ * Structural (Pick+cast): the plugin only touches the read/notify surface;
+ * the service class carries private scan state a literal need not reproduce.
+ */
+type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
+function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
+  const graphListeners = new Set<() => void>()
+  const rebuiltCalls: string[] = []
+  const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
+    rebuiltCalls,
+    fireGraphChanged: () => { for (const l of graphListeners) l() },
+    graph: (): WebBootGraph => ({
+      rev: 'r',
+      entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
+    }),
+    clientPath: id => rows.get(id),
+    rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
+    onRebuilt: () => () => {},
+    onGraphChanged: (listener) => {
+      graphListeners.add(listener)
+      return () => { graphListeners.delete(listener) }
+    },
+  }
+  return fake as FakeHost
+}
+
+// Structural fake: the plugin only touches register(); the service class
+// carries private state a literal cannot (and need not) reproduce.
+function fakeHttpServer(routes: WebRoute[]): HttpServerService {
+  const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
+    register(route) {
+      routes.push(route)
+      return () => { routes.splice(routes.indexOf(route), 1) }
+    },
+    tapIndex: () => () => {},
+    port: 0,
+  }
+  return fake as HttpServerService
+}
+
+async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
+  const ctx = new Context()
+  ctx.provide('clientModuleHost', clientModuleHost)
+  ctx.provide('httpServer', httpServer)
+  const fiber = ctx.plugin(
+    { inject: [...inject], Config, apply },
+    { pollIntervalMs: POLL_MS },
+  )
+  await fiber.await()
+  return fiber
+}
 
 describe('hmr node half', () => {
-  it('apply is a no-op host placeholder', () => {
-    apply()
-    expect(true).toBe(true) // reaching here without throw is the contract
+  it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
+    const bundle = join(dir, 'a.js')
+    writeFileSync(bundle, 'v1')
+    const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
+    const routes: WebRoute[] = []
+    const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
+
+    expect(routes).toHaveLength(1)
+    expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
+
+    // Nudge mtime past stat granularity so the poller sees a content signal.
+    await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
+    writeFileSync(bundle, 'v2-longer')
+    await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
+
+    await fiber.dispose()
+    expect(routes).toHaveLength(0)
+    // Watcher gone: further file changes report nothing.
+    clientModuleHost.rebuiltCalls.length = 0
+    writeFileSync(bundle, 'v3-even-longer')
+    await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
+    expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
+  })
+
+  it('follows graph changes: rows added after activation get watched', async () => {
+    const early = join(dir, 'early.js')
+    const late = join(dir, 'late.js')
+    writeFileSync(early, 'v1')
+    const rows = new Map([['pkg-early', early]])
+    const clientModuleHost = fakeClientModuleHost(rows)
+    const fiber = await mount(clientModuleHost, fakeHttpServer([]))
+
+    writeFileSync(late, 'v1')
+    rows.set('pkg-late', late)
+    clientModuleHost.fireGraphChanged()
+
+    await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
+    writeFileSync(late, 'v2-longer')
+    await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
+    await fiber.dispose()
   })
 })

+ 7 - 1
packages/client/hmr/tsconfig.json

@@ -8,7 +8,7 @@
       "DOM",
       "DOM.Iterable"
     ],
-    "types": []
+    "types": ["node"]
   },
   "include": [
     "src"
@@ -23,6 +23,12 @@
     {
       "path": "../modules"
     },
+    {
+      "path": "../../host/webserver"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../support/invariants"
     }

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

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
-  "description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
+  "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -11,6 +11,10 @@
       "types": "./lib/types/index.d.ts",
       "default": "./lib/index.js"
     },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
     "./invariant": {
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
@@ -18,14 +22,26 @@
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
+  "dshClient": {
+    "platform": "web",
+    "inject": [],
+    "immediately": true
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
   "license": "BSD-3-Clause",
   "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "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"

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

@@ -0,0 +1,34 @@
+/**
+ * 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,
+ * design §4.7 — 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.
+ * @module @deepseek-ai/dsh-client-modules/client
+ */
+import type { Context } from 'cordis'
+import type { DshWindow } from './manifest.ts'
+
+export { ClientModuleSystem } from './system.ts'
+export { parseBootManifest } from './manifest.ts'
+export type {
+  BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
+  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
+} from './manifest.ts'
+
+/**
+ * Enroll the kernel-built module system as `ctx.modules`.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: Context): void {
+  const modules = (globalThis as DshWindow).__DSH_MODULES__
+  // The kernel writes the slot right after constructing the instance, before
+  // any cordis entry exists — a missing slot means the kernel sequencing broke.
+  if (modules === undefined) {
+    throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
+  }
+  ctx.reflect.provide('modules', modules)
+}

+ 243 - 0
packages/client/modules/src/client/manifest.ts

@@ -0,0 +1,243 @@
+/**
+ * Client module system: the browser peer of Node's internal ESM loader, built
+ * as a lazy CJS table. The vendored cordis Loader consumes this object
+ * through its `internal` seam (the only call site is `EntryTree.import` →
+ * `internal.import`), which keeps entry governance (fiber lifecycle, inject
+ * waiting, update/refresh) entirely on the vendored side while this package
+ * owns code arrival.
+ *
+ * Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
+ * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
+ * side effect — including CSS injection — lives inside the factory closure
+ * and runs at materialization, not at script execution. Materialization
+ * (factory(require) → export surface) happens on first import/require and is
+ * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
+ * another registered-but-unmaterialized module materializes it recursively,
+ * so load order needs no external sequencing.
+ *
+ * Resolution branch order (import): seed word → shell instance; memoized
+ * record → surface; static registry (shell-own modules, e.g. app-shell) →
+ * module; registered factory → materialize; graph row → fetch + execute +
+ * materialize; anything else → throw (loud — the runtime mirror of the
+ * build-time bundle purity gate). The synchronous `require` handed to
+ * factories walks the same order minus the fetch branch: fetching is async,
+ * so only already-executed bundles can be required — and cross-plugin value
+ * imports are a build error anyway.
+ *
+ * This file is the browser-safe contract face (zero node imports): the
+ * `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
+ * {@link ClientModuleSystem}. The package root is the host-side service that
+ * composes the wire.
+ */
+
+import type {} from 'cordis'
+import type { ClientModuleSystem } from './system.ts'
+
+declare module 'cordis' {
+  interface Context {
+    /** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
+    modules: ClientModuleLoader
+  }
+}
+
+/**
+ * One composed client entry pushed by the host (web2 §0 graph row). Wire
+ * 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 dshClient
+ * declaration and reach fibers through entry creation).
+ */
+export interface WebBootEntry {
+  /** Entry name == package name. */
+  id: string
+  /** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
+  url: string
+  /** Bundle content hash (cache-busting consistency anchor). */
+  rev: string
+  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+  inject?: string[]
+  /** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
+  immediately?: boolean
+}
+
+/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
+export interface WebBootGraph {
+  /** Consistency anchor over the whole graph (content + bundle hashes). */
+  rev: string
+  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
+  entries: WebBootEntry[]
+}
+
+/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
+export interface BootModuleRow {
+  /** Entry name == package name (module-table key). */
+  id: string
+  /** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
+  url: string
+  /** Bundle content hash. */
+  rev: string
+}
+
+/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
+export interface BootPluginRow {
+  /** Entry name == package name. */
+  id: string
+  /** Package-name dependency edges ([] when the wire omits them). */
+  inject: string[]
+  /** Stage-one prefetch tier (false when the wire omits it). */
+  immediately: boolean
+}
+
+/** The parsed boot manifest: one wire, two consumer views. */
+export interface BootManifest {
+  /** Consistency anchor over the whole graph. */
+  rev: string
+  /** Rows as the module table consumes them. */
+  modules: BootModuleRow[]
+  /** Rows as entry composition consumes them. */
+  plugins: BootPluginRow[]
+}
+
+/**
+ * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
+ * a missing or malformed graph throws (the shell shows the loud failure —
+ * a page without a valid manifest cannot boot anything).
+ * @param wire - the raw `window.__DSH_BOOT__` value.
+ * @returns the manifest with optional plugin-view fields normalized.
+ */
+export function parseBootManifest(wire: unknown): BootManifest {
+  if (typeof wire !== 'object' || wire === null) {
+    throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
+  }
+  const graph = wire as Record<string, unknown>
+  if (typeof graph.rev !== 'string') {
+    throw new Error('client-modules: boot manifest rev must be a string')
+  }
+  if (!Array.isArray(graph.entries)) {
+    throw new Error('client-modules: boot manifest entries must be an array')
+  }
+  const modules: BootModuleRow[] = []
+  const plugins: BootPluginRow[] = []
+  for (const value of graph.entries as unknown[]) {
+    if (typeof value !== 'object' || value === null) {
+      throw new Error('client-modules: boot manifest entry is not an object')
+    }
+    const row = value as Record<string, unknown>
+    const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
+    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`)
+    }
+    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 })
+    plugins.push({
+      id: row.id,
+      inject: row.inject === undefined ? [] : [...row.inject as string[]],
+      immediately: row.immediately === true,
+    })
+  }
+  return { rev: graph.rev, modules, plugins }
+}
+
+/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
+export interface ClientPluginHandoff {
+  /** Plugin id (package name) — the registration key; must match the graph row being executed. */
+  id: string
+  /**
+   * Closure factory holding the whole bundle body: receives the synchronous
+   * require bound to the module table and returns the bundle's export
+   * surface. Runs once, at materialization.
+   */
+  factory: (require: (spec: string) => unknown) => Record<string, unknown>
+}
+
+/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the 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 (contract C6). */
+  __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
+  /**
+   * Kernel handoff slot: the shell kernel stores the instance here right
+   * after construction (before cordis exists) so the `./client` wrapper
+   * plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
+   * time = kernel sequencing bug, thrown loud.
+   */
+  __DSH_MODULES__?: ClientModuleSystem
+}
+
+/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
+export interface ClientModuleRecord {
+  /** Module id (entry name / package name). */
+  id: string
+  /** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
+  surface: unknown
+  /** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
+  styles: string[]
+  /** Observed `require()` edges (module-graph seam; only table words can appear today). */
+  edges: Set<string>
+}
+
+/**
+ * The internal-seam subset the vendored Loader and the client HMR plugin
+ * consume. Mounted on `ctx.loader.internal` by the shell boot and provided
+ * as `ctx.modules` (contract C5).
+ */
+export interface ClientModuleLoader {
+  /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
+  version: 'client'
+  /** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
+  loadCache: Map<string, ClientModuleRecord>
+  /**
+   * Internal seam consumed by the vendored Loader's `tree.import`. Resolves
+   * `specifier` through the branch order documented on the module, fetching
+   * and executing a bundle when needed.
+   * @param specifier - module specifier (entry name or table word).
+   * @param parentURL - importer URL (unused — the client module graph is flat).
+   * @param attrs - import attributes (unused; interface parity with Node's seam).
+   * @returns the module's export surface.
+   */
+  import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
+  /**
+   * Register a shell-own module (app-shell — code that ships inside the shell
+   * bundle and never arrives as a plugin bundle).
+   * @param id - entry name (shell-owned pseudo id).
+   * @param module - the statically imported module namespace.
+   */
+  registerStatic(id: string, module: unknown): void
+  /**
+   * Stage-one arrival: fetch the entry's bundle and execute it, registering
+   * its factory (no materialization — module side effects wait for import).
+   * No-op for static-registered ids and ids whose factory is already
+   * registered; concurrent calls share one in-flight task. To force a fresh
+   * fetch (HMR), {@link invalidate} first.
+   * @param id - graph entry name.
+   */
+  prefetch(id: string): Promise<void>
+  /**
+   * Full reset of one module: drop its registered factory, its materialized
+   * record, and any consumed bundle text, so the next prefetch/import
+   * refetches and re-executes (the HMR invalidation hook).
+   * @param id - entry name to invalidate.
+   */
+  invalidate(id: string): void
+}
+
+/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
+export interface ClientModuleSystemOptions {
+  /** Boot rows in the module-table view (from {@link parseBootManifest}). */
+  modules: BootModuleRow[]
+  /** Module-table seed: platform-singleton specifier → shell instance. */
+  staticModules: Record<string, unknown>
+  /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
+  fetchBundle?: (url: string) => Promise<string>
+  /**
+   * Bundle execution seam (synchronously performs the load() registration).
+   * Defaults to a <script> element carrying the code.
+   */
+  executeBundle?: (code: string, url: string) => void
+}

+ 17 - 25
packages/client/modules/src/loader.ts → packages/client/modules/src/client/system.ts

@@ -1,13 +1,13 @@
 /**
- * ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
+ * ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
  * seam. The conceptual contract (lazy CJS model, resolution branch order) is
- * documented on the package module and the public interfaces in `./index.ts`;
- * this file owns the state tables and the fetch/execute/materialize machinery.
+ * documented on the public interfaces in `./manifest.ts`; this file owns the
+ * state tables and the fetch/execute/materialize machinery.
  */
 import type {
-  ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
-  ClientPluginHandoff, DshWindow, WebBootEntry,
-} from './index.ts'
+  BootModuleRow, ClientModuleLoader, ClientModuleRecord,
+  ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
+} from './manifest.ts'
 
 /** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
 interface RegisteredFactory {
@@ -35,13 +35,6 @@ const defaultExecuteBundle = (code: string, url: string): void => {
   el.remove()
 }
 
-const urlOf = (row: WebBootEntry): string => {
-  // url is conditional on the wire (shell-own pseudo rows omit it); those
-  // ids resolve through the static registry and never reach a fetch.
-  if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
-  return row.url
-}
-
 /**
  * A plugin bundle IS its package's client half: `<id>/client` (the exports
  * subpath external bundles emit) and the bare graph id name the same
@@ -70,10 +63,10 @@ const claimStyles = (id: string): string[] => {
 /**
  * The client module system: state tables plus the arrival/materialization
  * machinery implementing {@link ClientModuleLoader} (whose members carry the
- * seam contract docs). Construction indexes the boot graph and installs the
+ * seam contract docs). Construction indexes the boot rows and installs the
  * `window.__ModuleLoader__` registration sink (contract C6) — once per page.
  */
-export class ClientModuleLoaderImpl implements ClientModuleLoader {
+export class ClientModuleSystem implements ClientModuleLoader {
   readonly version = 'client'
   readonly loadCache = new Map<string, ClientModuleRecord>()
 
@@ -84,7 +77,7 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
   private readonly pendingArrival = new Map<string, Promise<void>>()
   /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
   private readonly materializing = new Set<string>()
-  private readonly graphRows = new Map<string, WebBootEntry>()
+  private readonly graphRows = new Map<string, BootModuleRow>()
   // Execution URL of the bundle currently being executed (bound into the
   // factory registration so diagnostics can name the source).
   private executingUrl = ''
@@ -97,17 +90,17 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
   private readonly executeBundle: (code: string, url: string) => void
 
   /**
-   * Build the module system over the host graph.
-   * @param options - entry graph, module-table staticModules, fetch/execute seams.
+   * Build the module system over the parsed boot rows.
+   * @param options - module rows, module-table staticModules, fetch/execute seams.
    */
-  constructor(options: ClientModuleLoaderOptions) {
+  constructor(options: ClientModuleSystemOptions) {
     this.seed = new Map(Object.entries(options.staticModules))
     this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
     this.executeBundle = options.executeBundle ?? defaultExecuteBundle
 
-    for (const entry of options.graph.entries) {
-      if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
-      this.graphRows.set(entry.id, entry)
+    for (const row of options.modules) {
+      if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
+      this.graphRows.set(row.id, row)
     }
 
     const win = globalThis as DshWindow
@@ -129,13 +122,12 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
   }
 
   /** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
-  private arrive(row: WebBootEntry): Promise<void> {
-    const { id } = row
+  private arrive(row: BootModuleRow): Promise<void> {
+    const { id, url } = row
     const pending = this.pendingArrival.get(id)
     if (pending !== undefined) return pending
     if (this.factories.has(id)) return Promise.resolve()
     const task = (async (): Promise<void> => {
-      const url = urlOf(row)
       const code = await this.fetchBundle(url)
       this.executingUrl = url
       this.executingId = id

+ 354 - 136
packages/client/modules/src/index.ts

@@ -1,175 +1,393 @@
 /**
- * Client module system: the browser peer of Node's internal ESM loader, built
- * as a lazy CJS table. The vendored cordis Loader consumes this object
- * through its `internal` seam (the only call site is `EntryTree.import` →
- * `internal.import`), which keeps entry governance (fiber lifecycle, inject
- * waiting, update/refresh) entirely on the vendored side while this package
- * owns code arrival.
+ * Node half of the client module system (dshClient dual-face package): scans
+ * the host Loader's entries for `dshClient` packages, composes the
+ * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
+ * in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
+ * index render to inject the boot manifest, and provides the
+ * `clientModuleHost` service (the HMR node half's registration/notification
+ * face).
  *
- * Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
- * factory (`window.__ModuleLoader__.load({id, factory})`); every module body
- * side effect — including CSS injection — lives inside the factory closure
- * and runs at materialization, not at script execution. Materialization
- * (factory(require) → export surface) happens on first import/require and is
- * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
- * another registered-but-unmaterialized module materializes it recursively,
- * so load order needs no external sequencing.
- *
- * Resolution branch order (import): seed word → shell instance; memoized
- * record → surface; static registry (shell-own modules, e.g. app-shell) →
- * module; registered factory → materialize; graph row → fetch + execute +
- * materialize; anything else → throw (loud — the runtime mirror of the
- * build-time bundle purity gate). The synchronous `require` handed to
- * factories walks the same order minus the fetch branch: fetching is async,
- * so only already-executed bundles can be required — and cross-plugin value
- * imports are a build error anyway.
+ * Scanning is incremental per package — there is no full-rescan code path.
+ * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
+ * the fiber's entry name dirty; a microtask flush reconciles each dirty name
+ * against the live loader entries. The activation pass seeds the same dirty
+ * set with all current entries and flushes synchronously, so first scan and
+ * steady state share one implementation. Package metadata (including the
+ * negative "not a client package" verdict) is cached per name and never
+ * expires — plugin-set changes take effect on restart per the config-source
+ * ruling; bundle content changes reach the graph only through
+ * {@link ClientModuleHostService.rebuilt}.
  * @module @deepseek-ai/dsh-client-modules
  */
 
-import { ClientModuleLoaderImpl } from './loader.ts'
+import { createHash } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { Service } from 'cordis'
+import type { Context } from 'cordis'
+import type {} from '@cordisjs/plugin-loader'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
-export { ClientModuleLoaderImpl }
+export type {
+  BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
+} from './client/manifest.ts'
 
 declare module 'cordis' {
   interface Context {
-    /** The client module system the web shell provides at boot (contract C5). */
-    modules: ClientModuleLoader
+    /** The web plugin table (provided by the client-modules node half). */
+    clientModuleHost: ClientModuleHostService
   }
 }
 
-/**
- * One composed client entry pushed by the host (web2 §0 graph row).
- * `immediately` marks stage-one prefetch; `inject` is informational graph
- * metadata (the authoritative edges live in each package's dshClient
- * declaration and reach fibers through entry creation).
- *
- * Wire contract, held on both sides: the producing peer lives in
- * `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
- * dependencies, so neither side imports the other's shape — drift between
- * the two declarations is a bug against the web2 contract).
- */
-export interface WebBootEntry {
-  /** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
-  id: string
-  /**
-   * Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
-   * shell-owned pseudo rows (app-shell) whose module is statically registered
-   * — a row that is neither fetchable nor static-registered fails loud.
-   */
-  url?: string
-  /** Bundle content hash (cache-busting consistency anchor); absent with url. */
-  rev?: string
-  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
+interface DshClientDeclaration {
   inject?: string[]
-  /** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
+  platform: string
+  /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
   immediately?: boolean
 }
 
-/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
-export interface WebBootGraph {
-  /** Consistency anchor over the whole graph (content + bundle hashes). */
-  rev: string
-  /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
-  entries: WebBootEntry[]
+/** Resolved package metadata for one dshClient package (cached per name, never expires). */
+interface PkgMeta {
+  clientPath: string
+  inject?: string[]
+  immediately: boolean
 }
 
-/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
-export interface ClientPluginHandoff {
-  /** Plugin id (package name) — the registration key; must match the graph row being executed. */
-  id: string
-  /**
-   * Closure factory holding the whole bundle body: receives the synchronous
-   * require bound to the module table and returns the bundle's export
-   * surface. Runs once, at materialization.
-   */
-  factory: (require: (spec: string) => unknown) => Record<string, unknown>
+/** One composed table row: the wire entry plus its bundle path. */
+interface WebPluginRecord {
+  entry: WebBootEntry
+  clientPath: string
 }
 
-/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
-export interface DshWindow {
-  /** Host-composed entry graph, injected before the shell bundle runs. */
-  __DSH_BOOT__?: WebBootGraph
-  /** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
-  __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
+/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
+function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
+  if (value === undefined) return undefined
+  if (typeof value !== 'object' || value === null) {
+    throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
+  }
+  const decl = value as Record<string, unknown>
+  if (typeof decl.platform !== 'string') {
+    throw new Error(`client-modules: ${pkgName} dshClient.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} dshClient.inject must be a string array`)
+  }
+  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
+    throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
+  }
+  return {
+    platform: decl.platform,
+    ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
+    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
+  }
 }
 
-/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
-export interface ClientModuleRecord {
-  /** Module id (entry name / package name). */
-  id: string
-  /** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
-  surface: unknown
-  /** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
-  styles: string[]
-  /** Observed `require()` edges (module-graph seam; only table words can appear today). */
-  edges: Set<string>
+/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
+function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
+  if (typeof exportsField !== 'object' || exportsField === null) return undefined
+  const client = (exportsField as Record<string, unknown>)['./client']
+  if (client === undefined) return undefined
+  if (typeof client === 'string') return client
+  if (typeof client === 'object' && client !== null) {
+    const fallback = (client as Record<string, unknown>).default
+    if (typeof fallback === 'string') return fallback
+  }
+  throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`)
+}
+
+/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
+function shortHash(input: string | Buffer): string {
+  return createHash('sha1').update(input).digest('hex').slice(0, 12)
+}
+
+/** 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 {
+  return {
+    id,
+    url: `/plugins/${id}/client.js?rev=${rev}`,
+    rev,
+    ...(injectEdges !== undefined ? { inject: injectEdges } : {}),
+    ...(immediately ? { immediately: true } : {}),
+  }
+}
+
+/**
+ * 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.
+ * @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 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.
+  return `${script}${html}`
 }
 
 /**
- * The internal-seam subset the vendored Loader and the client HMR plugin
- * consume. Mounted on `ctx.loader.internal` by the shell boot and provided
- * as `ctx.modules` (contract C5).
+ * The web plugin table service: incremental dshClient scan + wire composition
+ * + bundle route + index tap. Construction runs the activation scan
+ * synchronously — a malformed declaration or missing bundle among the
+ * already-loaded entries aggregates into one loud throw (FAILED fiber; the
+ * boot sweep reports it).
  */
-export interface ClientModuleLoader {
-  /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
-  version: 'client'
-  /** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
-  loadCache: Map<string, ClientModuleRecord>
+export class ClientModuleHostService extends Service {
+  static inject = ['httpServer', 'loader']
+
+  private readonly table = new Map<string, WebPluginRecord>()
+  // Negative verdicts (unresolvable specifier — builtins like cordis:include,
+  // subpath rows — or a package without a web dshClient declaration) are
+  // cached as null and never expire: plugin-set changes take effect on restart.
+  private readonly pkgMeta = new Map<string, PkgMeta | null>()
+  private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
+  private readonly graphListeners = new Set<() => void>()
+  private readonly dirty = new Set<string>()
+  private readonly resolvePkgJson: (spec: string) => string
+  private flushQueued = false
+  private composed: WebBootGraph
+
   /**
-   * Internal seam consumed by the vendored Loader's `tree.import`. Resolves
-   * `specifier` through the branch order documented on the module, fetching
-   * and executing a bundle when needed.
-   * @param specifier - module specifier (entry name or table word).
-   * @param parentURL - importer URL (unused — the client module graph is flat).
-   * @param attrs - import attributes (unused; interface parity with Node's seam).
-   * @returns the module's export surface.
+   * Build the service: subscribe, seed, and run the activation flush.
+   * @param ctx - plugin context carrying httpServer and loader.
    */
-  import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
+  constructor(ctx: Context) {
+    super(ctx, 'clientModuleHost')
+    // Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
+    // whose package declares every composed plugin as a dependency). The
+    // modules package's own URL would miss sibling packages under pnpm's
+    // isolated node_modules.
+    if (ctx.baseUrl === undefined) {
+      throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
+    }
+    const require = createRequire(ctx.baseUrl)
+    this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
+
+    // Subscribe before seeding so a fiber arriving mid-activation lands in the
+    // same dirty set (Set idempotence makes the overlap harmless). An entry-less
+    // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
+    ctx.on('internal/plugin', (fiber) => {
+      const entryName = fiber.entry?.options.name
+      if (entryName === undefined) return
+      this.dirty.add(entryName)
+      if (this.flushQueued) return
+      this.flushQueued = true
+      queueMicrotask(() => {
+        this.flushQueued = false
+        this.flush((err) => { ctx.logger.warn(err) })
+      })
+    })
+
+    // Activation pass: the initial scan IS the incremental path over the
+    // current entries, flushed synchronously (nothing async between subscribe,
+    // seed, and flush).
+    for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
+    this.composed = this.compose()
+    const failures: Error[] = []
+    this.flush(err => failures.push(err))
+    if (failures.length > 0) {
+      throw new AggregateError(
+        failures,
+        `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => `  - ${e.message}`).join('\n')}`,
+      )
+    }
+
+    ctx.effect(
+      () => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
+      'client-modules: bundle route',
+    )
+    ctx.effect(
+      () => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)),
+      'client-modules: boot manifest injection',
+    )
+  }
+
   /**
-   * 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.
+   * Current composed entry graph (stable object between changes).
+   * @returns the graph served as `window.__DSH_BOOT__`.
    */
-  registerStatic(id: string, module: unknown): void
+  graph(): WebBootGraph {
+    return this.composed
+  }
+
   /**
-   * Stage-one arrival: fetch the entry's bundle and execute it, registering
-   * its factory (no materialization — module side effects wait for import).
-   * No-op for static-registered ids and ids whose factory is already
-   * registered; concurrent calls share one in-flight task. To force a fresh
-   * fetch (HMR), {@link invalidate} first.
-   * @param id - graph entry name.
+   * Absolute path of an entry's client bundle.
+   * @param id - entry id (package name).
+   * @returns the path, or undefined for an unknown id.
    */
-  prefetch(id: string): Promise<void>
+  clientPath(id: string): string | undefined {
+    return this.table.get(id)?.clientPath
+  }
+
   /**
-   * Full reset of one module: drop its registered factory, its materialized
-   * record, and any consumed bundle text, so the next prefetch/import
-   * refetches and re-executes (the HMR invalidation hook).
-   * @param id - entry name to invalidate.
+   * Re-hash one bundle (the HMR watch's registration hook — the only entry
+   * point through which bundle content changes reach the graph).
+   * @param id - entry id (package name).
+   * @returns the new rev, or undefined for an unknown id.
    */
-  invalidate(id: string): void
-}
+  rebuilt(id: string): string | undefined {
+    const record = this.table.get(id)
+    if (record === undefined) return undefined
+    const rev = shortHash(readFileSync(record.clientPath))
+    if (rev === record.entry.rev) return rev
+    record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
+    this.composed = this.compose()
+    for (const notify of this.rebuildListeners) {
+      // Containment: rebuilt() runs inside the HMR watch callback — a
+      // throwing subscriber must not kill the poll or skip later subscribers.
+      try {
+        notify(id, rev)
+      } catch (error) {
+        this.ctx.logger.error(error)
+      }
+    }
+    this.notifyGraphChanged()
+    return rev
+  }
 
-/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
-export interface ClientModuleLoaderOptions {
-  /** Host-composed entry graph. */
-  graph: WebBootGraph
-  /** Module-table seed: platform-singleton specifier → shell instance. */
-  staticModules: Record<string, unknown>
-  /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
-  fetchBundle?: (url: string) => Promise<string>
   /**
-   * Bundle execution seam (synchronously performs the load() registration).
-   * Defaults to a <script> element carrying the code.
+   * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
+   * @param listener - receives the entry id and its new bundle rev.
+   * @returns the unsubscriber.
    */
-  executeBundle?: (code: string, url: string) => void
-}
+  onRebuilt(listener: (id: string, rev: string) => void): () => void {
+    this.rebuildListeners.add(listener)
+    return () => { this.rebuildListeners.delete(listener) }
+  }
 
-/**
- * Build the client module system.
- * @param options - entry graph, module-table staticModules, fetch/execute seams.
- * @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
- */
-export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
-  return new ClientModuleLoaderImpl(options)
+  /**
+   * Fires after any flush that recomposed the graph (row added/removed, or a
+   * rebuilt rev change). Pull model: listeners re-read {@link graph}.
+   * @param listener - notified with no payload.
+   * @returns the unsubscriber.
+   */
+  onGraphChanged(listener: () => void): () => void {
+    this.graphListeners.add(listener)
+    return () => { this.graphListeners.delete(listener) }
+  }
+
+  private compose(): WebBootGraph {
+    const entries = [...this.table.values()].map(record => record.entry)
+    return { rev: shortHash(JSON.stringify(entries)), entries }
+  }
+
+  private notifyGraphChanged(): void {
+    for (const listener of this.graphListeners) {
+      // A throwing subscriber must not skip later subscribers (or escape into
+      // whatever triggered the flush — possibly an fs.watchFile callback).
+      try {
+        listener()
+      } catch (error) {
+        this.ctx.logger.error(error)
+      }
+    }
+  }
+
+  private resolveMeta(pkgName: string): PkgMeta | null {
+    const cached = this.pkgMeta.get(pkgName)
+    if (cached !== undefined) return cached
+    let pkgPath: string
+    try {
+      pkgPath = this.resolvePkgJson(pkgName)
+    } catch {
+      // Not a resolvable package root: loader builtins (cordis:include) and
+      // subpath entries (…/gateway) land here — permanently not a client row.
+      this.pkgMeta.set(pkgName, null)
+      return null
+    }
+    const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
+    const decl = parseDshClient(pkgName, pkg.dshClient)
+    if (decl === undefined || decl.platform !== 'web') {
+      this.pkgMeta.set(pkgName, null)
+      return null
+    }
+    const clientRel = clientExportOf(pkgName, pkg.exports)
+    if (clientRel === undefined) {
+      throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
+    }
+    const meta: PkgMeta = {
+      clientPath: join(dirname(pkgPath), clientRel),
+      ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
+      immediately: decl.immediately === true,
+    }
+    this.pkgMeta.set(pkgName, meta)
+    return meta
+  }
+
+  /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
+  private processOne(entryName: string): boolean {
+    let qualifies = false
+    for (const entry of this.ctx.loader.entries()) {
+      if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
+        qualifies = true
+        break
+      }
+    }
+    if (!qualifies) return this.table.delete(entryName)
+    if (this.table.has(entryName)) return false
+    const meta = this.resolveMeta(entryName)
+    if (meta === null) return false
+    // 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 = shortHash(readFileSync(meta.clientPath))
+    this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
+    return true
+  }
+
+  private flush(onError: (err: Error) => void): void {
+    let changed = false
+    for (const entryName of [...this.dirty]) {
+      this.dirty.delete(entryName)
+      try {
+        if (this.processOne(entryName)) changed = true
+      } catch (error) {
+        // Steady state: one broken package must not poison the others; the
+        // activation pass aggregates these into a loud throw instead.
+        onError(error instanceof Error ? error : new Error(String(error)))
+      }
+    }
+    if (changed) {
+      this.composed = this.compose()
+      this.notifyGraphChanged()
+    }
+  }
+
+  private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
+    if (req.method !== 'GET' && req.method !== 'HEAD') {
+      res.writeHead(405)
+      res.end()
+      return
+    }
+    /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
+    const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
+    // The id may contain a scope slash. Anything else under /plugins (including
+    // /plugins/events when the HMR row is absent) is an unknown resource.
+    const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
+      ? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
+      : undefined
+    if (path === undefined) {
+      res.writeHead(404)
+      res.end()
+      return
+    }
+    try {
+      const body = await readFile(path)
+      res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
+      res.end(body)
+    } catch {
+      // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
+      res.writeHead(404)
+      res.end()
+    }
+  }
 }
+
+export default ClientModuleHostService

+ 18 - 7
packages/client/modules/src/invariant.ts

@@ -15,14 +15,25 @@ export const name = 'client-modules-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: the module loader is pre-plugin kernel machinery —
- * it emits no cordis events (the vendored Loader owns entry lifecycle events)
- * and its mutable state (loadCache, handoff slot) lives below the plugin
- * layer where invariant observers cannot mount before it runs; resolve branch
- * order and handoff discipline are asserted by the web boot specs against the
- * real execution path.
+ * Owned relation: the node half's boot entry graph must stay self-consistent
+ * — every row must resolve a clientPath under the same id (the
+ * /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
+ * that just received the graph). Checked on every scan trigger (cordis
+ * 'internal/plugin'): graph() and clientPath() read the same table object,
+ * so the relation holds at any instant — no need to wait out the node half's
+ * own microtask-debounced flush.
  */
-const install: InvariantInstaller = () => {}
+const install: InvariantInstaller = (ctx, fail) => {
+  ctx.on('internal/plugin', () => {
+    const host = ctx.get('clientModuleHost')
+    if (host === undefined) return // browser side / host without the node half: nothing to audit
+    for (const row of host.graph().entries) {
+      if (host.clientPath(row.id) === undefined) {
+        fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
+      }
+    }
+  }, { global: true })
+}
 
 /**
  * Register this package's invariant companion.

+ 12 - 17
packages/client/modules/tests/loader.spec.ts

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 /**
- * ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
+ * ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
  * registers the factory), materialization on first import/require with
  * memoization and recursive self-sequencing, the resolution branch order,
  * shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
@@ -9,9 +9,9 @@
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
-  ClientModuleLoaderImpl, createClientModuleLoader,
-  type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
-} from '../src/index.ts'
+  ClientModuleSystem,
+  type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
+} from '../src/client/index.ts'
 
 const win = globalThis as DshWindow
 
@@ -24,7 +24,7 @@ afterEach(() => {
   for (const el of document.querySelectorAll('style, script')) el.remove()
 })
 
-const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
+const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
 
 interface Bench {
   loader: ClientModuleLoader
@@ -38,14 +38,14 @@ interface Bench {
  * through the window sink (`null` scripts a bundle that never calls load).
  */
 function bench(
-  entries: WebBootEntry[],
+  entries: BootModuleRow[],
   bundles: Record<string, Factory | null> = {},
   opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
 ): Bench {
   const fetched: string[] = []
   const gates = new Map<string, () => void>()
-  const loader = createClientModuleLoader({
-    graph: { rev: 'test', entries },
+  const loader = new ClientModuleSystem({
+    modules: entries,
     staticModules: opts.seed ?? {},
     fetchBundle: (url) => {
       fetched.push(url)
@@ -175,7 +175,7 @@ describe('require resolution', () => {
 describe('static registry', () => {
   it('serves shell-own modules to import and require without any fetch', async () => {
     const shell = { marker: 'app-shell' }
-    const b = bench([row('a'), { id: 'app-shell' }], {
+    const b = bench([row('a')], {
       a: req => ({ dep: req('app-shell') }),
     })
     b.loader.registerStatic('app-shell', shell)
@@ -216,18 +216,13 @@ describe('failure modes', () => {
     await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
   })
 
-  it('a graph row with no url and no static registration is loud', async () => {
-    const b = bench([{ id: 'ghost' }])
-    await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
-  })
-
   it('a duplicate graph entry is loud at construction', () => {
     expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
   })
 
   it('double boot is loud', () => {
     bench([])
-    expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
+    expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
       .toThrow('already installed (double boot?)')
   })
 })
@@ -289,7 +284,7 @@ describe('default transport seams', () => {
     const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
       + 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
     vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
-    const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
+    const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
     ;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
     const surface = await loader.import('dee', '', {})
     expect((surface as { marker: string }).marker).toBe('via-script')
@@ -300,7 +295,7 @@ describe('default transport seams', () => {
 
   it('a non-ok bundle response is loud with the status', async () => {
     vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
-    const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
+    const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
     await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
   })
 })

+ 7 - 15
packages/client/modules/tsconfig.json

@@ -3,22 +3,14 @@
   "compilerOptions": {
     "rootDir": "src",
     "outDir": "lib/types",
-    "lib": [
-      "ES2024",
-      "DOM",
-      "DOM.Iterable"
-    ],
-    "types": []
+    "lib": ["ES2024", "DOM", "DOM.Iterable"],
+    "types": ["node"]
   },
-  "include": [
-    "src"
-  ],
+  "include": ["src"],
   "references": [
-    {
-      "path": "../../../vendor/cordis"
-    },
-    {
-      "path": "../../support/invariants"
-    }
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/loader" },
+    { "path": "../../host/webserver" },
+    { "path": "../../support/invariants" }
   ]
 }

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

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

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

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-web
 
-Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
+Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
 
 Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
 

+ 209 - 143
packages/client/web/src/boot.tsx

@@ -1,23 +1,32 @@
 /**
- * Web shell boot — the kernel face consumed by the apps/web entry. Everything
- * here is machinery that cannot itself be an entry, and none of it
+ * Web shell boot kernel — the face consumed by the apps/web entry. Everything
+ * here is machinery that cannot itself be a loader entry, and none of it
  * value-imports a plugin package (web2 shell self-sufficiency rule: the
- * loading page must work while — especially when — plugins fail).
+ * loading page must work while — especially when — plugins fail). The one
+ * sanctioned exception is the modules package (design §4.7 bootstrap
+ * identity): the module system cannot arrive through itself, so its class
+ * and its client-half wrapper are shell-bundled and the kernel adopts its
+ * plugin entry once cordis is up.
  *
- * Two-stage boot (web2 §0):
- *   Stage one (module face): build the module system over the host graph
- *   (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
- *   — fetch + execute registers factories only; module side effects wait for
- *   materialization. Prefetch failures are non-fatal here: stage two's
- *   import path retries the fetch and owns the loud failure.
- *   Stage two (plugin face): mount the vendored cordis Loader, inject the
- *   module system as its internal seam (BEFORE any entry exists — the
- *   bare-import fallback in tree.import must never run in a browser), create
- *   one loader entry per graph row (tree.import materializes each module),
- *   let fibers activate on service availability, then loader.await() + a
- *   full fiber sweep (all ACTIVE, else reject listing who/what/which
- *   service) → flip the settled signal so AppRoot switches to the real UI in
- *   one pass.
+ * AppWebEntry.run(), module face first, then plugin face: parse
+ * `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
+ * → build the module system over the module-view rows → render the loading
+ * page → prefetch every `immediately` row in parallel with mounting the
+ * vendored cordis Loader (internal-seam injection BEFORE any entry exists —
+ * the bare-import fallback in tree.import must never run in a browser) →
+ * await the prefetch tier, THEN adopt the modules entry and create one
+ * loader entry per plugin-view row plus the shell-own app-shell assembly
+ * entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
+ * listing who/what/which service) → flip the settled signal so AppRoot
+ * switches to the real UI in one pass.
+ *
+ * Entry creation waits for the whole immediately tier: materialization runs
+ * synchronous cross-package require edges (e.g. i18n → runtime/client) that
+ * fiber inject waiting cannot protect — a bundle's factory must be
+ * registered before any dependent entry materializes. Per-row prefetch
+ * failures still resolve silently (the create-side import refetches and
+ * owns the loud failure), so the barrier never turns one bad bundle into a
+ * boot-wide fail-fast.
  *
  * Composition lives in the host graph; the shell makes zero composition
  * decisions (the app-shell assembly is itself a graph entry, the only
@@ -25,148 +34,205 @@
  */
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
-import { createRoot } from 'react-dom/client'
+import { createRoot, type Root } from 'react-dom/client'
+import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
 import {
-  createClientModuleLoader,
-  type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
-} from '@deepseek-ai/dsh-client-modules'
+  ClientModuleSystem, parseBootManifest,
+  type BootManifest, type ClientModuleSystemOptions, type DshWindow,
+} from '@deepseek-ai/dsh-client-modules/client'
 import * as AppShell from './app-shell.ts'
 import { APP_SHELL_ID } from './app-shell.ts'
 import { AppRoot } from './AppRoot.tsx'
 import { getStaticModules } from './seed.ts'
-import {
-  STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
-} from './loader-status.ts'
+import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
 import './base.css'
 
 /** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
-export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
+export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
 
 /**
- * Sweep every loader entry after the tree quiesced: an entry without a fiber
- * failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
- * (a required service never arrived — cordis inject waiting has no timeout,
- * so this sweep is the fail-loud compensation).
+ * The modules package's own graph row id. The kernel adopts that entry
+ * itself (its wrapper is statically registered — shell-bundled code, never
+ * fetched), so the plugin-row loop must skip it: the vendored Group.create
+ * does not deduplicate by name, and a second fiber would provide 'modules'
+ * twice.
  */
-function assertEntriesActive(ctx: Context): void {
-  const failures: string[] = []
-  for (const entry of ctx.loader.entries()) {
-    const name = entry.options.name
-    if (entry.fiber === undefined) {
-      failures.push(`${name}: import failed (see console for the import error)`)
-      continue
-    }
-    const state = STATE_LABELS[entry.fiber.state]
-    if (state === 'active') continue
-    if (state === 'pending') {
-      const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
-      failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
-    } else {
-      failures.push(`${name}: ${state}`)
+const MODULES_ID = '@deepseek-ai/dsh-client-modules'
+
+/**
+ * The web shell kernel: mounts the loading page into a DOM element and runs
+ * the two-stage boot over the host graph. Fields hold only what must exist
+ * before cordis does — the parsed manifest, the module system, and the
+ * loading-page UI handles; everything else lives in plugins.
+ */
+export class AppWebEntry {
+  private readonly el: HTMLElement
+  private readonly seams: BootSeams | undefined
+  private readonly status = createLoaderStatusStore()
+  private readonly settled = createSignal(false)
+  private readonly error = createSignal<string | undefined>(undefined)
+  // Assigned by run() before any private method or settled-gated closure reads them.
+  private ctx!: Context
+  private modules!: ClientModuleSystem
+  private manifest!: BootManifest
+  private root: Root | undefined
+
+  /**
+   * Hold the mount point; all work happens in {@link run}.
+   * @param el - mount point (the app's #root).
+   * @param seams - optional module transport overrides (test environments).
+   */
+  constructor(el: HTMLElement, seams?: BootSeams) {
+    this.el = el
+    this.seams = seams
+  }
+
+  /**
+   * Run the boot chain to settlement. Boot-chain failures resolve (not
+   * reject): the loading page stays up and renders the failure report (the
+   * fail-loud surface the kernel owns). Rejects only when the boot manifest
+   * is missing or malformed — there is nothing to boot against.
+   * @returns resolves once the UI settled or the failure report rendered.
+   */
+  async run(): Promise<void> {
+    this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
+
+    this.modules = new ClientModuleSystem({
+      modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
+    })
+    // The app-shell assembly is the only shell-own module: every other graph
+    // row is a plugin bundle arriving through fetch (web2 single package form).
+    this.modules.registerStatic(APP_SHELL_ID, AppShell)
+    // Adoption handoff, supply side (design §4.7): register the modules
+    // package's own client half under its bare package name (= graph row id
+    // = entry name — a suffixed key would miss the statics branch and
+    // trigger a real fetch), and put the instance on the kernel slot the
+    // wrapper's apply reads to provide ctx.modules.
+    this.modules.registerStatic(MODULES_ID, ModulesClient)
+    ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
+
+    this.root = createRoot(this.el)
+    this.root.render(
+      <AppRoot
+        settled={this.settled}
+        status={this.status}
+        error={this.error}
+        renderApp={() => {
+          const shell = this.ctx.get('appShell')
+          // Unreachable after a clean settle (the app-shell entry is in every graph).
+          if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
+          return shell.renderApp()
+        }}
+      />,
+    )
+
+    // The immediately tier prefetches in parallel with Loader mounting;
+    // runPluginBoot awaits it before creating entries (see module comment:
+    // cross-package synchronous require edges need every immediately-tier
+    // factory registered before any materialization).
+    const prefetching = this.prefetchImmediateTier()
+    this.ctx = new Context()
+    try {
+      await this.runPluginBoot(prefetching)
+      this.settled.set(true)
+    } catch (reason) {
+      // Stay on the loading page; surface the sweep report (fail loud).
+      console.error(reason)
+      this.error.set(reason instanceof Error ? reason.message : String(reason))
     }
   }
-  if (failures.length > 0) {
-    throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+
+  /** Unmount the shell (loading page or settled UI). */
+  dispose(): void {
+    this.root?.unmount()
   }
-}
 
-/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
-async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
-  await Promise.all(graph.entries
-    .filter((row) => row.immediately === true)
-    .map((row) => modules.prefetch(row.id).catch(() => {
-      // Import (stage two) refetches and reports this loudly per entry;
-      // swallowing here keeps one failing prefetch from masking the others.
-    })))
-}
+  /** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
+  private async prefetchImmediateTier(): Promise<void> {
+    await Promise.all(this.manifest.plugins
+      .filter((row) => row.immediately)
+      .map((row) => this.modules.prefetch(row.id).catch(() => {
+        // Import refetches and reports this loudly per entry; swallowing
+        // here keeps one failing prefetch from masking the others.
+      })))
+  }
 
-/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
-async function runPluginBoot(
-  ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
-): Promise<void> {
-  await ctx.plugin(Loader)
-  const loader = ctx.loader
-  // Inject the module system BEFORE any entry exists: tree.import falls back
-  // to a bare dynamic import when internal is undefined, which in a browser
-  // is a guaranteed loud failure — correct as a tripwire, never as a path.
-  loader.internal = modules as never
-
-  // Status projection: AppRoot displays fiber truth. Every internal/status
-  // transition under an entry re-projects that entry's row from its ROOT
-  // fiber (child plugin fibers share the same entry).
-  ctx.on('internal/status', (fiber) => {
-    const entry = fiber.entry
-    if (entry === undefined || entry.fiber === undefined) return
-    status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
-  })
-
-  // Entry creation order carries no semantics (fiber inject waiting owns
-  // activation order); creating concurrently lets non-prefetched bundle
-  // fetches parallelize. The app-shell assembly entry is appended by the
-  // kernel: it is shell-own code (host graph rows are all plugin bundles),
-  // and mounting the assembly is not a composition decision — it rides the
-  // same entry lifecycle so the sweep and status cover it uniformly.
-  const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
-  await Promise.all(rows.map(async (name) => {
-    status.set(name, 'loading')
-    const id = await loader.create({ name })
-    // A failed import leaves the entry fiberless (Entry._init logs and
-    // returns); project it as failed — no fiber means no status event.
-    if (loader.resolve(id).fiber === undefined) {
-      status.set(name, 'failed')
-    }
-  }))
+  /** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
+  private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
+    const ctx = this.ctx
+    await ctx.plugin(Loader)
+    const loader = ctx.loader
+    // Inject the module system BEFORE any entry exists: tree.import falls back
+    // to a bare dynamic import when internal is undefined, which in a browser
+    // is a guaranteed loud failure — correct as a tripwire, never as a path.
+    loader.internal = this.modules as never
 
-  await loader.await()
-  assertEntriesActive(ctx)
-}
+    // Status projection: AppRoot displays fiber truth. Every internal/status
+    // transition under an entry re-projects that entry's row from its ROOT
+    // fiber (child plugin fibers share the same entry).
+    ctx.on('internal/status', (fiber) => {
+      const entry = fiber.entry
+      if (entry === undefined || entry.fiber === undefined) return
+      this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
+    })
 
-/**
- * Mount the web shell into a DOM element and start the two-stage boot chain.
- * @param el - mount point (the app's #root).
- * @param seams - optional module transport overrides (test environments).
- * @returns unmount disposer.
- */
-export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
-  const graph = (globalThis as DshWindow).__DSH_BOOT__
-  if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
-
-  const ctx = new Context()
-  const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
-  // The app-shell assembly is the only shell-own module: every other graph
-  // row is a plugin bundle arriving through fetch (web2 single package form).
-  modules.registerStatic(APP_SHELL_ID, AppShell)
-  // Contract C5: the module system is a boot-owned kernel service (ctx.modules).
-  ctx.reflect.provide('modules', modules)
-
-  const status = createLoaderStatusStore()
-  const settled = createSignal(false)
-  const error = createSignal<string | undefined>(undefined)
-
-  const root = createRoot(el)
-  root.render(
-    <AppRoot
-      settled={settled}
-      status={status}
-      error={error}
-      renderApp={() => {
-        const shell = ctx.get('appShell')
-        // Unreachable after a clean settle (the app-shell entry is in every graph).
-        if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
-        return shell.renderApp()
-      }}
-    />,
-  )
-
-  prefetchImmediateTier(modules, graph)
-    .then(() => runPluginBoot(ctx, modules, graph, status))
-    .then(
-      () => { settled.set(true) },
-      (reason: unknown) => {
-        // Stay on the loading page; surface the sweep report (fail loud).
-        console.error(reason)
-        error.set(reason instanceof Error ? reason.message : String(reason))
-      },
-    )
-  return () => { root.unmount() }
+    // Barrier before any entry exists: entry creation materializes bundles,
+    // and materialization runs synchronous cross-package require edges that
+    // need every immediately-tier factory already registered (module
+    // comment). Resolves even when individual prefetches failed.
+    await prefetching
+
+    // Adoption handoff, plugin side: the modules entry is created first —
+    // its wrapper apply reads the kernel slot and provides ctx.modules (the
+    // provide lives on the plugin face; see MODULES_ID for why the row loop
+    // must then skip it).
+    const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
+    // Entry creation order carries no semantics (fiber inject waiting owns
+    // activation order); creating concurrently lets non-prefetched bundle
+    // fetches parallelize. The app-shell assembly entry is appended by the
+    // kernel: it is shell-own code (host graph rows are all plugin bundles),
+    // and mounting the assembly is not a composition decision — it rides the
+    // same entry lifecycle so the sweep and status cover it uniformly.
+    await Promise.all(rows.map(async (name) => {
+      this.status.set(name, 'loading')
+      const id = await loader.create({ name })
+      // A failed import leaves the entry fiberless (Entry._init logs and
+      // returns); project it as failed — no fiber means no status event.
+      if (loader.resolve(id).fiber === undefined) {
+        this.status.set(name, 'failed')
+      }
+    }))
+
+    await loader.await()
+    this.assertEntriesActive()
+  }
+
+  /**
+   * Sweep every loader entry after the tree quiesced: an entry without a
+   * fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
+   * PENDING (a required service never arrived — cordis inject waiting has no
+   * timeout, so this sweep is the fail-loud compensation).
+   */
+  private assertEntriesActive(): void {
+    const ctx = this.ctx
+    const failures: string[] = []
+    for (const entry of ctx.loader.entries()) {
+      const name = entry.options.name
+      if (entry.fiber === undefined) {
+        failures.push(`${name}: import failed (see console for the import error)`)
+        continue
+      }
+      const state = STATE_LABELS[entry.fiber.state]
+      if (state === 'active') continue
+      if (state === 'pending') {
+        const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
+        failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
+      } else {
+        failures.push(`${name}: ${state}`)
+      }
+    }
+    if (failures.length > 0) {
+      throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
+    }
+  }
 }

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

@@ -1,13 +1,13 @@
 /**
- * Web shell library entry. The shell's product is {@link bootWebShell} —
- * apps/web's vite entry calls it against #root; everything else (AppRoot
+ * Web shell library entry. The shell's product is {@link AppWebEntry} —
+ * apps/web's vite entry runs it against #root; everything else (AppRoot
  * gate, app-shell assembly entry, module-table staticModules, platform constants) is
  * internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
  * single source of truth for the tsdown client externals projection.
  * @module @deepseek-ai/dsh-client-web
  */
 
-export { bootWebShell, type BootSeams } from './boot.tsx'
+export { AppWebEntry, type BootSeams } from './boot.tsx'
 export { AppRoot, type AppRootProps } from './AppRoot.tsx'
 export { buildRenderApp, type AssemblyDeps } from './app.tsx'
 export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'

+ 48 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -188,6 +188,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'clientModuleHost',
+    summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
+    methods: [
+      {
+        signature: 'graph(): WebBootGraph',
+        jsDoc: '/**\n * Current composed entry graph (stable object between changes).\n * @returns the graph served as `window.__DSH_BOOT__`.\n */',
+      },
+      {
+        signature: 'clientPath(id: string): string | undefined',
+        jsDoc: '/**\n * Absolute path of an entry\'s client bundle.\n * @param id - entry id (package name).\n * @returns the path, or undefined for an unknown id.\n */',
+      },
+      {
+        signature: 'rebuilt(id: string): string | undefined',
+        jsDoc: '/**\n * Re-hash one bundle (the HMR watch\'s registration hook — the only entry\n * point through which bundle content changes reach the graph).\n * @param id - entry id (package name).\n * @returns the new rev, or undefined for an unknown id.\n */',
+      },
+      {
+        signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
+        jsDoc: '/**\n * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.\n * @param listener - receives the entry id and its new bundle rev.\n * @returns the unsubscriber.\n */',
+      },
+      {
+        signature: 'onGraphChanged(listener: () => void): () => void',
+        jsDoc: '/**\n * Fires after any flush that recomposed the graph (row added/removed, or a\n * rebuilt rev change). Pull model: listeners re-read {@link graph}.\n * @param listener - notified with no payload.\n * @returns the unsubscriber.\n */',
+      },
+    ],
+  },
   {
     key: 'codeRuntime',
     summary: 'Registers one `ctx.codeRuntime` implementation.',
@@ -314,6 +340,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'httpServer',
+    summary: 'The web-shape HTTP carrier service.',
+    methods: [
+      {
+        signature: 'register(route: WebRoute): () => void',
+        jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */',
+      },
+      {
+        signature: 'tapIndex(transform: (html: string) => string): () => void',
+        jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
+      },
+    ],
+  },
   {
     key: 'invariants',
     summary: 'Package-owned invariant registry with global and regex-based selection.',
@@ -2338,6 +2378,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WebFetchResult',
     declaration: 'export interface WebFetchResult {\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
   },
+  {
+    name: 'WebRoute',
+    declaration: 'export interface WebRoute {\n    kind: WebRouteKind;\n    path: string;\n    handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
+  },
+  {
+    name: 'WebRouteKind',
+    declaration: 'export type WebRouteKind = \'exact\' | \'prefix\';',
+  },
   {
     name: 'WebSearchProvider',
     declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    available(): boolean;\n    search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',

+ 2 - 2
packages/host/apiproxy/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-apiproxy
 
-The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
+The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`.
 
 ## Contract layer (`/api`)
 
@@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
+- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
 - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
 - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.

+ 5 - 1
packages/host/apiproxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-host-apiproxy",
-  "description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
+  "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -40,12 +40,16 @@
   ],
   "license": "BSD-3-Clause",
   "dependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "schemastery": "^3.18.0",
     "zod": "^4.4.3"
   },
   "peerDependencies": {

+ 9 - 7
packages/host/runtime/src/api-proxy.ts → packages/host/apiproxy/src/api-proxy.ts

@@ -11,12 +11,14 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
+// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
+import type {} from '@deepseek-ai/dsh-tools'
 import type {
   ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
-} from '@deepseek-ai/dsh-host-apiproxy/api'
-import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
-import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+} from './api/index.ts'
+import { questionResponsePayloadSchema } from './api/questions.schema.ts'
+import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
+import { RpcId } from './api/rpc.ts'
 import type {
   AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
 } from '@deepseek-ai/dsh-user-interaction'
@@ -170,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
   }
 }
 
-/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
+/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */
 export interface ApiProxyDefaults {
   provider: string
   model: string
@@ -272,8 +274,8 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
 class SessionNotFound extends Error {}
 
 /**
- * Implement ApiProxy over the ctx composed by bootHost.
- * @param ctx - the root context returned by bootHost (sessions/agents services mounted).
+ * Implement ApiProxy over a composed host context.
+ * @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services).
  * @param defaults - host-level default provider/model: injected as
  * agentOptions on create/resume, reported by describe from the same source.
  * @returns the ApiProxy implementation.

+ 62 - 5
packages/host/apiproxy/src/index.ts

@@ -1,13 +1,70 @@
 /**
- * @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
- * the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
- * fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
- * platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
- * lives in @deepseek-ai/dsh-host-runtime.
+ * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
+ * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
+ * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
+ * platform subclasses on the client side), and the host-side implementation
+ * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
+ * `ctx.apiProxy`). Transport-agnostic by design: this package registers no
+ * routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
  */
 
+import { Context, Service } from 'cordis'
+import z from 'schemastery'
+import type { ApiProxy } from './api/index.ts'
+import { createApiProxy } from './api-proxy.ts'
+
 export type * from './api/index.ts'
 export { RpcId } from './api/rpc.ts'
 export { toFetchHandler } from './fetch/handler.ts'
 export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
 export type { IApiClient } from './fetch/client.ts'
+export { createApiProxy } from './api-proxy.ts'
+export type { ApiProxyDefaults } from './api-proxy.ts'
+
+declare module 'cordis' {
+  interface Context {
+    /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
+    apiProxy: ApiProxy
+  }
+}
+
+/** Gateway plugin config: the host-level default agent routing. */
+export interface Config {
+  /** Default provider route for created/resumed agents. */
+  provider: string
+  /** Default model id. */
+  model: string
+}
+
+/**
+ * The API gateway service: implements the ApiProxy contract over the composed
+ * host context and provides it as `ctx.apiProxy`. The default project
+ * directory for new sessions is the host process working directory (not a
+ * config field this round).
+ */
+export class ApiProxyService extends Service implements ApiProxy {
+  static inject = ['agents', 'sessions', 'tools', 'userInteraction']
+
+  static Config: z<Config> = z.object({
+    provider: z.string().required(),
+    model: z.string().required(),
+  })
+
+  readonly sessions: ApiProxy['sessions']
+  readonly host: ApiProxy['host']
+  readonly events: ApiProxy['events']
+  readonly respond: ApiProxy['respond']
+
+  constructor(ctx: Context, config: Config) {
+    super(ctx, 'apiProxy')
+    const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() })
+    this.sessions = api.sessions
+    this.host = api.host
+    this.events = api.events
+    // createApiProxy returns closures (no `this` capture); bind only satisfies
+    // the unbound-method lint without changing behavior.
+    this.respond = api.respond.bind(api)
+  }
+}
+
+export default ApiProxyService

+ 6 - 5
packages/host/apiproxy/src/invariant.ts

@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: this package is the wire contract layer (types,
- * schemas, fetch carrier glue) — it emits no cordis events and owns no
- * mutable cross-plugin relation. rpcId round-trip and schema acceptance are
- * enforced at the carrier boundary and exercised by the protocol-isomorphism
- * suite; the live implementation relations belong to dsh-host-runtime.
+ * No runtime invariant: this package is the wire contract layer plus the
+ * host-side gateway over services owned elsewhere — it emits no cordis events
+ * of its own; the session/agent event streams it projects are asserted by
+ * their owning packages' companions. rpcId round-trip and schema acceptance
+ * are enforced at the carrier boundary and exercised by the
+ * protocol-isomorphism suite.
  */
 const install: InvariantInstaller = () => {}
 

+ 15 - 0
packages/host/apiproxy/tsconfig.json

@@ -8,18 +8,33 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../util/brand"
     },
     {
       "path": "../../llm/llm"
     },
+    {
+      "path": "../../core/agent"
+    },
     {
       "path": "../../core/session"
     },
     {
       "path": "../../core/tools"
     },
+    {
+      "path": "../../session-persistence/session-persistence"
+    },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../ui/user-approval"
     },

+ 1 - 1
packages/host/runtime/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-runtime
 
-Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
+Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition).
 
 Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
 

+ 0 - 1
packages/host/runtime/package.json

@@ -39,7 +39,6 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",

+ 3 - 6
packages/host/runtime/src/index.ts

@@ -1,14 +1,11 @@
 /**
  * @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
- * composition (bootHost), the ApiProxy implementation (createApiProxy), and
- * the one-step shell seam (startHost). Host-level configuration (defaults,
- * persistenceRoot, future user profile) lives here.
+ * composition (bootHost) and the one-step shell seam (startHost). The ApiProxy
+ * implementation lives in @deepseek-ai/dsh-host-apiproxy. Host-level
+ * configuration (defaults, persistenceRoot, future user profile) lives here.
  */
 
 export { bootHost } from './boot.ts'
 export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
-export { createApiProxy } from './api-proxy.ts'
-export type { ApiProxyDefaults } from './api-proxy.ts'
 export { startHost } from './start.ts'
 export type { StartHostOptions, RunningHost } from './start.ts'
-export { mountWebPlugins } from './web-plugins.ts'

+ 1 - 2
packages/host/runtime/src/start.ts

@@ -8,10 +8,9 @@
 
 import type { Context } from 'cordis'
 import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
+import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 import { bootHost } from './boot.ts'
 import type { BootHostOptions, HostDefaults } from './boot.ts'
-import { createApiProxy } from './api-proxy.ts'
 
 /** Options for startHost. */
 export interface StartHostOptions {

+ 0 - 57
packages/host/runtime/src/web-plugins.ts

@@ -1,57 +0,0 @@
-/**
- * Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
- * entry tree over the caller-supplied client plugin roster. The roster is a
- * composition decision and lives in the composing app (apps/cli); this module
- * only owns the mount/settle/fail-loud mechanics. The web plugin registry
- * discovers fetch-arrival entries among the mounted packages by their
- * package.json dshClient declarations; node halves are empty applies, so
- * mounting them here costs nothing beyond Loader governance.
- */
-import { createRequire } from 'node:module'
-import type { Context } from 'cordis'
-import Loader from '@cordisjs/plugin-loader'
-
-/** What the shell hands the web plugin registry (loader view + module resolution seam). */
-export interface MountedWebPlugins {
-  /** Entry enumeration surface of the mounted Loader (registry scan source). */
-  loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
-  /** Resolve a plugin package's package.json absolute path. */
-  resolvePkgJson: (name: string) => string
-}
-
-/**
- * Mount the Loader (when absent) and create one in-memory entry per client
- * plugin package, then wait for the tree to settle. A plugin whose import
- * fails leaves its entry fiber-less — surfaced here as a loud throw listing
- * the failures (misconfiguration must not silently drop a client plugin).
- * @param ctx - host root context (bootHost product).
- * @param plugins - client plugin package names to mount (the composition layer's roster).
- * @param anchor - module URL anchoring bare-specifier resolution (the composing
- * app's import.meta.url; the roster packages must be dependencies of that app).
- * @returns the loader view and package.json resolver the registry consumes.
- */
-export async function mountWebPlugins(
-  ctx: Context, plugins: readonly string[], anchor: string,
-): Promise<MountedWebPlugins> {
-  // The Loader resolves bare specifiers against ctx.baseUrl; without one the
-  // import silently fails and every entry stays fiber-less. The composing app
-  // declares the roster packages as dependencies, so its URL is the right anchor.
-  ctx.baseUrl ??= anchor
-  if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
-  const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
-  for (const name of plugins) {
-    if (!existing.has(name)) await ctx.loader.create({ name })
-  }
-  await ctx.loader.await()
-  const dead = [...ctx.loader.entries()]
-    .filter(entry => plugins.includes(entry.options.name))
-    .filter(entry => entry.fiber === undefined && !entry.disabled)
-  if (dead.length > 0) {
-    throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
-  }
-  const require = createRequire(anchor)
-  return {
-    loader: ctx.loader,
-    resolvePkgJson: name => require.resolve(`${name}/package.json`),
-  }
-}

+ 1 - 1
packages/host/runtime/tests/api-proxy-cold.spec.ts

@@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { createApiProxy } from '../src/api-proxy.ts'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
 
 const sid = (id: string): SessionId => id as SessionId
 

+ 1 - 1
packages/host/runtime/tests/api-proxy-view.spec.ts

@@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { createApiProxy } from '../src/api-proxy.ts'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
 
 const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
 

+ 0 - 111
packages/host/runtime/tests/web-plugins.spec.ts

@@ -1,111 +0,0 @@
-/**
- * mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
- * baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
- * fail-loud sweep, and the resolver seam — is exercised against a stubbed
- * loader service so it runs without built lib/ artifacts. The roster is
- * caller-supplied now (composition moved to apps/cli), so these tests pass
- * their own lists.
- */
-import { Context } from 'cordis'
-import { afterEach, describe, expect, it } from 'vitest'
-import { mountWebPlugins } from '../src/web-plugins.ts'
-
-const ROSTER = [
-  '@deepseek-ai/dsh-plugin-a',
-  '@deepseek-ai/dsh-plugin-b',
-  '@deepseek-ai/dsh-plugin-c',
-] as const
-
-interface FakeEntry {
-  options: { name: string }
-  fiber?: unknown
-  disabled: boolean
-}
-
-/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
-class FakeLoader {
-  readonly created: string[] = []
-  awaited = 0
-  constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
-  entries(): Iterable<FakeEntry> {
-    return this.entriesList
-  }
-  async create(options: { name: string }): Promise<void> {
-    this.created.push(options.name)
-    this.onCreate?.(options.name)
-  }
-  async await(): Promise<void> {
-    this.awaited += 1
-  }
-}
-
-let root: Context | undefined
-
-afterEach(async () => {
-  await root?.fiber.dispose()
-  root = undefined
-})
-
-function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
-  root = new Context()
-  const loader = new FakeLoader(entriesList, onCreate)
-  root.reflect.provide('loader', loader)
-  return { ctx: root, loader }
-}
-
-describe('mountWebPlugins (stubbed loader)', () => {
-  it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
-    const entriesList: FakeEntry[] = []
-    const { ctx, loader } = withLoader(entriesList, (name) => {
-      entriesList.push({ options: { name }, fiber: {}, disabled: false })
-    })
-    const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
-    expect(loader.created).toEqual([...ROSTER])
-    expect(loader.awaited).toBe(1)
-    expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
-    // The resolver resolves a real package manifest through real module resolution, anchored at this test file.
-    expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
-    expect(ctx.baseUrl).toBeDefined()
-  })
-
-  it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
-    const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
-    const { ctx, loader } = withLoader(preexisting)
-    await mountWebPlugins(ctx, ROSTER, import.meta.url)
-    expect(loader.created).toEqual([])
-  })
-
-  it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
-    const entriesList: FakeEntry[] = []
-    const { ctx } = withLoader(entriesList, (name) => {
-      // First one loads; the rest stay fiber-less (import failed silently).
-      entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
-    })
-    await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
-      .rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
-  })
-
-  it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
-    const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
-    const { ctx } = withLoader(entriesList)
-    await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
-  })
-
-  it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
-    root = new Context()
-    // An empty roster keeps this keyless and artifact-free: the branch under
-    // test is only the Loader auto-mount.
-    await mountWebPlugins(root, [], import.meta.url)
-    expect(root.get('loader') !== undefined).toBe(true)
-  }, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
-
-  it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
-    const entriesList: FakeEntry[] = []
-    const { ctx } = withLoader(entriesList, (name) => {
-      entriesList.push({ options: { name }, fiber: {}, disabled: false })
-    })
-    ctx.baseUrl = 'file:///caller/anchor/'
-    await mountWebPlugins(ctx, ROSTER, import.meta.url)
-    expect(ctx.baseUrl).toBe('file:///caller/anchor/')
-  })
-})

+ 6 - 8
packages/host/webserver/README.md

@@ -1,18 +1,16 @@
 # @deepseek-ai/dsh-host-webserver
 
-Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
+Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
 
-The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
+The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
 
-Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
-
-A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
+A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
 
 In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
 
 ## Model Experience
 
-None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.
+None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
 
 #### KV Cache effect
 
@@ -20,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
+- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
 - **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
-- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
+- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.

+ 4 - 1
packages/host/webserver/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-host-webserver",
-  "description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)",
+  "description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -30,6 +30,9 @@
     "cordis": "^4.0.0-rc.7",
     "@deepseek-ai/dsh-invariants": "^0.0.1"
   },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
   "devDependencies": {
     "cordis": "^4.0.0-rc.7",
     "@deepseek-ai/dsh-invariants": "workspace:^"

+ 154 - 202
packages/host/webserver/src/index.ts

@@ -1,232 +1,184 @@
 /**
- * @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server
- * routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG
- * bridge with SSE streamed out chunk by chunk) and everything else to static
- * file serving. Web (browser) shape only — Electron loads dist over file://
- * and carries fetch over an IPC bridge, not this server. This package never
- * prints: the URL line belongs to the shell.
+ * @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
+ * node:http server plus the `httpServer` service (named-route registry + index
+ * transform taps + static dist fallback). Knows no harness concepts — every
+ * feature surface (API bridge, plugin bundles, SSE) is a route some other
+ * plugin registers. Web (browser) shape only — Electron loads dist over
+ * file:// and carries fetch over an IPC bridge, not this server. This package
+ * never prints: the URL line belongs to the shell.
  */
 
 import { createServer } from 'node:http'
-import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { IncomingMessage, ServerResponse, Server } from 'node:http'
 import { readFile } from 'node:fs/promises'
 import type { AddressInfo } from 'node:net'
 import { dirname } from 'node:path'
+import { Context, Service } from 'cordis'
+import z from 'schemastery'
 import { serveStatic } from './static.ts'
-import { createPluginEventChannel } from './plugin-events.ts'
-import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts'
-
-export { createHostWebPluginRegistry } from './web-plugins.ts'
-export type {
-  HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps,
-} from './web-plugins.ts'
-export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts'
-
-/** Options for startWebServer. */
-export interface WebServerOptions {
-  /** Address or hostname to listen on. */
-  host: string
-  /** Port to listen on; zero requests an OS-assigned port. */
-  port: number
-  /**
-   * Absolute path of index.html inside the static root — the caller resolves
-   * it (dist location is workspace knowledge of the shell, not this package's).
-   */
-  distIndex: string
-  /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
-  apiHandler: { fetch: typeof fetch }
-  /**
-   * Web plugin table. When present, every index.html response carries the
-   * `window.__DSH_BOOT__` entry graph script, `/plugins/<id>/client.js` serves
-   * each fetch entry's client bundle, and `GET /plugins/events` streams graph/
-   * rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch
-   * notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only
-   * use).
-   */
-  webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
+
+declare module 'cordis' {
+  interface Context {
+    httpServer: HttpServerService
+  }
+}
+
+/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
+export type WebRouteKind = 'exact' | 'prefix'
+
+/** One named route registration. */
+export interface WebRoute {
+  kind: WebRouteKind
+  /** Absolute pathname, no trailing slash. */
+  path: string
+  /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
+  handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
 }
 
-/** Listening web server handle. */
-export interface RunningWebServer {
-  /** The listening port, including the OS-assigned value when options.port is zero. */
+/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
+export interface Config {
+  /** Listen host; the two supported values are loopback and all-interfaces. */
+  host: '127.0.0.1' | '0.0.0.0'
+  /** Listen port; zero requests an OS-assigned port. */
   port: number
-  /**
-   * Shutdown: close + closeAllConnections (SSE connections never end on their
-   * own; without the force-close, close() would hang). Idempotent.
-   */
-  close(): Promise<void>
+  /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
+  distIndex: string
 }
 
 /**
- * Start the web-shape HTTP server on the caller-selected host and port.
- * Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
- * static with the step1-locked semantics (403 traversal, SPA fallback 200).
- * A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
- * server error after listen goes to onError. A request whose handling throws
- * (malformed %-escapes, a client dropping mid-body) is answered 400 — or the
- * socket destroyed when headers are already out — and reported to onError;
- * it never becomes an unhandled rejection.
- * @param options - port, static root anchor, and the API carrier.
- * @param onError - sink for post-listen server errors and per-request handling failures.
- * @returns the running server handle once listening.
+ * The web-shape HTTP carrier service. Activation listens immediately (route
+ * registration order carries no request-facing semantics: named routes are
+ * composed to be disjoint, and the static dist fallback answers anything not
+ * yet claimed during the boot window). A listen failure throws out of init —
+ * a FAILED fiber the boot's fail-loud sweep reports.
  */
-export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
-  const { host, port, distIndex, apiHandler, webPlugins } = options
-  const distRoot = dirname(distIndex)
-  const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
-    const html = await readFile(distIndex, 'utf8')
-    return injectBootManifest(html, webPlugins.graph())
+export class HttpServerService extends Service {
+  static Config: z<Config> = z.object({
+    host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
+    port: z.natural().max(65535).required(),
+    distIndex: z.string().required(),
+  })
+
+  private readonly exact = new Map<string, WebRoute>()
+  private readonly prefixes = new Map<string, WebRoute>()
+  private readonly indexTaps: ((html: string) => string)[] = []
+  private readonly distRoot: string
+  private readonly distIndex: string
+  private server!: Server
+  private listenedPort!: number
+
+  constructor(ctx: Context, private config: Config) {
+    super(ctx, 'httpServer')
+    this.distIndex = config.distIndex
+    this.distRoot = dirname(config.distIndex)
   }
-  const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel()
-  // Rebuilt frames come from the registry's own bundle watch (dev mode); a
-  // prod registry without watching simply never notifies.
-  const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined
-    ? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) })
-    : undefined
-
-  const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
-    /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
-    requests; the field is only optional on the client-side IncomingMessage type */
-    const rawPath = new URL(req.url ?? '/', 'http://x').pathname
-    if (rawPath.startsWith('/api/')) {
-      await bridge(req, res, apiHandler)
-      return
-    }
-    if (req.method !== 'GET' && req.method !== 'HEAD') {
-      res.writeHead(405)
-      res.end()
-      return
-    }
-    if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') {
-      pluginEvents.connect(res, webPlugins.graph())
-      return
+
+  /** The listening port (the OS-assigned value when config.port is 0). */
+  get port(): number {
+    return this.listenedPort
+  }
+
+  /**
+   * Register a named route. Duplicate (kind, path) throws — route patterns are
+   * a composition-level contract, so a collision is a misconfiguration.
+   * @param route - kind, path, and the owning handler.
+   * @returns the disposer removing the route.
+   */
+  register(route: WebRoute): () => void {
+    const table = route.kind === 'exact' ? this.exact : this.prefixes
+    if (table.has(route.path)) {
+      throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
     }
-    if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
-      await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
-      return
+    table.set(route.path, route)
+    return () => { table.delete(route.path) }
+  }
+
+  /**
+   * Register an index.html transform, applied to every index response in
+   * registration order.
+   * @param transform - pure html-to-html function.
+   * @returns the disposer removing the transform.
+   */
+  tapIndex(transform: (html: string) => string): () => void {
+    this.indexTaps.push(transform)
+    return () => {
+      const at = this.indexTaps.indexOf(transform)
+      if (at !== -1) this.indexTaps.splice(at, 1)
     }
-    await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
   }
-  // Last-resort guard: handle() rejecting would otherwise be an unhandled
-  // rejection, and one malformed request (a bad %-escape hitting
-  // decodeURIComponent, a client dropping mid-body) would kill the whole
-  // process. Nothing after this catch can throw again on the same response.
-  const server = createServer((req, res) => {
-    handle(req, res).catch((err: unknown) => {
-      onError(err instanceof Error ? err : new Error(String(err)))
-      if (res.headersSent) {
-        res.destroy()
+
+  /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
+  async [Service.init](): Promise<void> {
+    const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
+      /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
+      requests; the field is only optional on the client-side IncomingMessage type */
+      const rawPath = new URL(req.url ?? '/', 'http://x').pathname
+      const route = this.match(rawPath)
+      if (route !== undefined) {
+        await route.handler(req, res)
+        return
+      }
+      // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
+      // traversal 403, miss falls back to index.html 200 (SPA routing).
+      if (req.method !== 'GET' && req.method !== 'HEAD') {
+        res.writeHead(405)
+        res.end()
         return
       }
-      res.writeHead(400)
-      res.end()
+      await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
+    }
+    // Last-resort guard: handle() rejecting would otherwise be an unhandled
+    // rejection killing the process on one malformed request (bad %-escape,
+    // client dropping mid-body). Per-request failures log and answer 400 —
+    // never a process exit.
+    this.server = createServer((req, res) => {
+      handle(req, res).catch((err: unknown) => {
+        this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
+        if (res.headersSent) {
+          res.destroy()
+          return
+        }
+        res.writeHead(400)
+        res.end()
+      })
     })
-  })
 
-  let closing: Promise<void> | undefined
-  const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
-    unsubscribeRebuilt?.()
-    server.close(() => { resolveClose() })
-    server.closeAllConnections()
-  }))
-
-  return new Promise((resolveListen, rejectListen) => {
-    server.once('error', rejectListen)
-    server.listen(port, host, () => {
-      server.off('error', rejectListen)
-      server.on('error', onError)
-      resolveListen({ port: (server.address() as AddressInfo).port, close })
+    await new Promise<void>((resolve, reject) => {
+      this.server.once('error', reject)
+      this.server.listen(this.config.port, this.config.host, () => {
+        this.server.off('error', reject)
+        this.server.on('error', (err) => { this.ctx.logger.error(err) })
+        this.listenedPort = (this.server.address() as AddressInfo).port
+        resolve()
+      })
     })
-  })
-}
 
-/**
- * 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.
- * @param html - the index.html source.
- * @param graph - the composed entry graph from the registry.
- * @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 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.
-  return `${script}${html}`
-}
-
-/**
- * Serve one plugin client bundle from the registry table (unknown id = 404;
- * the id may contain a scope slash). The `?rev=` query is a cache-busting
- * parameter only — serving ignores it; `no-cache` makes the browser revalidate
- * so a stale rev never sticks.
- */
-async function servePluginBundle(
-  pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
-): Promise<void> {
-  const id = pathname.slice('/plugins/'.length, -'/client.js'.length)
-  const path = webPlugins.clientPath(id)
-  if (path === undefined) {
-    res.writeHead(404)
-    res.end()
-    return
-  }
-  try {
-    const body = await readFile(path)
-    res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
-    res.end(body)
-  } catch {
-    // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
-    res.writeHead(404)
-    res.end()
+    // close + closeAllConnections: held-open responses (SSE) never end on
+    // their own; without the force-close, close() would hang teardown.
+    this.ctx.effect(() => () => new Promise<void>((resolve) => {
+      this.server.close(() => { resolve() })
+      this.server.closeAllConnections()
+    }), 'httpServer.listen')
   }
-}
 
-/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
-async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
-  const abort = new AbortController()
-  // Client-disconnect detection MUST hang off the response, not the request:
-  // since Node 16, IncomingMessage 'close' fires as soon as the request body is
-  // fully consumed (immediately for a bodyless GET), which would abort every SSE
-  // stream right after open. ServerResponse 'close' fires on connection teardown;
-  // writableEnded distinguishes a normal end() from the client going away.
-  res.on('close', () => {
-    if (!res.writableEnded) abort.abort()
-  })
-  const chunks: Buffer[] = []
-  for await (const chunk of req) chunks.push(chunk as Buffer)
-  /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
-  requests; the fields are only optional on the client-side IncomingMessage type */
-  const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
-    method: req.method ?? 'GET',
-    headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
-    ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
-    signal: abort.signal,
-  })
-  const response = await apiHandler.fetch(request)
-  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
-  if (response.body === null) {
-    res.end()
-    return
-  }
-  for await (const chunk of response.body) {
-    // Backpressure: a false return means the socket buffer is full — wait for drain
-    // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
-    // resolves so a mid-wait disconnect can't park this loop forever; the close
-    // handler above aborts the handler stream, which then ends the iteration.
-    if (!res.write(chunk)) {
-      await new Promise<void>((resolve) => {
-        const done = (): void => {
-          res.off('drain', done)
-          res.off('close', done)
-          resolve()
-        }
-        res.once('drain', done)
-        res.once('close', done)
-      })
+  /** Longest-prefix-wins over the prefix table after an exact-table miss. */
+  private match(pathname: string): WebRoute | undefined {
+    const exact = this.exact.get(pathname)
+    if (exact !== undefined) return exact
+    let best: WebRoute | undefined
+    for (const [prefix, route] of this.prefixes) {
+      if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
+      if (best === undefined || prefix.length > best.path.length) best = route
     }
+    return best
+  }
+
+  /** Index body: dist index.html through the registered taps in order. */
+  private async renderIndex(): Promise<string> {
+    let html = await readFile(this.distIndex, 'utf8')
+    for (const transform of this.indexTaps) html = transform(html)
+    return html
   }
-  res.end()
 }
+
+export default HttpServerService

+ 20 - 18
packages/host/webserver/src/invariant.ts

@@ -15,28 +15,30 @@ export const name = 'host-webserver-invariant'
 export const inject = ['invariants']
 
 /**
- * Owned relation: the web plugin registry's boot entry graph must stay
- * self-consistent — every row must resolve a clientPath under the same id
- * (the /plugins/<id>/client.js URL it advertises would otherwise 404 on a
- * browser that just received the graph). Checked synchronously on every
- * rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read
- * the same table object, so the relation is self-consistent at any instant —
- * no need to wait out the registry's own debounced rescan. The registry
- * arrives through the context key the assembly publishes it under.
+ * Owned relation: route registrations and their disposers must stay
+ * symmetric — after the owning fiber of a registered route unloads, the
+ * route table must no longer answer for its path (a stale route would keep
+ * serving a disposed plugin's handler). Checked on every fiber teardown
+ * (cordis 'internal/plugin'): the service's own registry state is compared
+ * against the set of live fibers' registrations indirectly, by probing that
+ * dispose really removed the entry — the register() disposer contract.
  */
 const install: InvariantInstaller = (ctx, fail) => {
   ctx.on('internal/plugin', () => {
-    const registry = ctx.get('webPlugins') as
-      | {
-        graph(): { entries: { id: string; url: string }[] }
-        clientPath(id: string): string | undefined
-      }
+    const server = ctx.get('httpServer') as
+      | { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
       | undefined
-    if (registry === undefined) return // carrier-only deployments never publish the registry
-    for (const row of registry.graph().entries) {
-      if (registry.clientPath(row.id) === undefined) {
-        fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
-      }
+    if (server === undefined) return // no webserver row in this composition
+    // Register/dispose probe on a reserved path: if dispose leaves the route
+    // behind, a second register throws the duplicate error — the asymmetry.
+    // Each register(probe)() is one register+dispose cycle, so the probe never
+    // leaves residue; a leftover from the first cycle makes the second throw.
+    const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} }
+    try {
+      server.register(probe)()
+      server.register(probe)()
+    } catch {
+      fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
     }
   }, { global: true })
 }

+ 0 - 56
packages/host/webserver/src/plugin-events.ts

@@ -1,56 +0,0 @@
-/**
- * `/plugins/events` SSE channel: the system-side push surface for the client
- * entry graph (connect → current graph frame; dev rebuild → rebuilt frame).
- * Presentation-only wire — frames never enter the session log (distinct from
- * the /api/* session SSE, which is api-contract territory). Connections are
- * plain node:http responses held in a set; the server's closeAllConnections
- * tears them down on shutdown.
- */
-
-import type { ServerResponse } from 'node:http'
-import type { WebBootGraph } from './web-plugins.ts'
-
-/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */
-export type PluginEventFrame =
-  | { type: 'graph'; graph: WebBootGraph }
-  | { type: 'rebuilt'; id: string; rev: string }
-
-/** Broadcast surface owned by the webserver routing layer. */
-export interface PluginEventChannel {
-  /** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */
-  connect(res: ServerResponse, graph: WebBootGraph): void
-  /** Push one frame to every open connection. */
-  broadcast(frame: PluginEventFrame): void
-}
-
-/** Serialize one frame as an SSE data line. */
-function sseData(frame: PluginEventFrame): string {
-  return `data: ${JSON.stringify(frame)}\n\n`
-}
-
-/**
- * Create the channel (one per running server).
- * @returns the connect/broadcast surface.
- */
-export function createPluginEventChannel(): PluginEventChannel {
-  const connections = new Set<ServerResponse>()
-  return {
-    connect(res, graph) {
-      res.writeHead(200, {
-        'content-type': 'text/event-stream',
-        'cache-control': 'no-cache',
-        'connection': 'keep-alive',
-      })
-      // Comment line on open so clients/proxies see a live channel even when
-      // no rebuild ever happens; EventSource frame parsing skips it naturally.
-      res.write(': connected\n\n')
-      res.write(sseData({ type: 'graph', graph }))
-      connections.add(res)
-      res.on('close', () => { connections.delete(res) })
-    },
-    broadcast(frame) {
-      const line = sseData(frame)
-      for (const res of connections) res.write(line)
-    },
-  }
-}

+ 0 - 360
packages/host/webserver/src/web-plugins.ts

@@ -1,360 +0,0 @@
-/**
- * HostWebPluginRegistry: composes the client entry graph served as
- * `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the
- * host Loader's loaded entries by its package.json `dshClient` declaration
- * (all client plugin packages arrive by fetch — one uniform bundle shape),
- * resolving each one's client bundle path from `exports["./client"]` and
- * hashing the bundle content into a `rev` (cache busting + HMR diff anchor).
- * `inject` edges and the `immediately` prefetch mark come from the manifest
- * (dshClient — the package owns its dependency edges and its boot tier); the
- * composition layer contributes only the roster. The webserver consumes the
- * table to emit the boot graph and to serve `GET /plugins/<id>/client.js`;
- * in dev mode the registry additionally stat-polls each scanned bundle file
- * and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild
- * signal is the registry's own observation — no builder protocol exists).
- *
- * The vendored loader emits no "entry loaded" event (only `loader/entry-init`,
- * which fires at Entry construction before import/apply), so the registry
- * scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber
- * create/dispose), microtask-debounced. Plugin-set changes take effect on
- * restart per the config-source ruling; the subscription only keeps the table
- * fresh within a process lifetime.
- */
-
-import { createHash } from 'node:crypto'
-import { readFileSync, statSync, type Stats } from 'node:fs'
-import { dirname, join } from 'node:path'
-import type { Context } from 'cordis'
-
-/** One composed client entry (`window.__DSH_BOOT__.entries` row). */
-export interface WebBootEntry {
-  /** Entry name == package name. */
-  id: string
-  /** Bundle URL served by this webserver (`/plugins/<id>/client.js?rev=<rev>`). */
-  url: string
-  /** Bundle content hash (sha1, shortened). */
-  rev: string
-  /** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */
-  inject?: string[]
-  /** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */
-  immediately?: boolean
-}
-
-/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */
-export interface WebBootGraph {
-  /** Consistency anchor over all rows: changes whenever any entry row changes. */
-  rev: string
-  /** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */
-  entries: WebBootEntry[]
-}
-
-/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */
-export interface HostWebPluginRegistry {
-  /** Current composed entry graph (stable object between changes). */
-  graph(): WebBootGraph
-  /**
-   * Absolute path of an entry's client bundle.
-   * @param id - entry id (package name).
-   * @returns the path, or undefined for an unknown id.
-   */
-  clientPath(id: string): string | undefined
-  /**
-   * Re-hash one entry's bundle: updates the row's rev/url and the graph rev.
-   * The dev bundle watch calls this on every observed file change.
-   * @param id - entry id (package name).
-   * @returns the new bundle rev, or undefined for an unknown id.
-   */
-  rebuilt(id: string): string | undefined
-  /**
-   * Subscribe to bundle rebuilds observed by the dev watch (only fires when
-   * the re-hash produced a different rev — an unchanged bundle is silent).
-   * @param listener - receives the entry id and its new bundle rev.
-   * @returns the unsubscriber.
-   */
-  onRebuilt(listener: (id: string, rev: string) => void): () => void
-  /** Remove the loader subscription, all bundle watches, and all rebuild listeners. */
-  dispose(): void
-}
-
-/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */
-export interface LoaderEntryView {
-  options: { name: string }
-  /** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */
-  fiber?: unknown
-  /** True when the entry or an owning group is disabled. */
-  disabled: boolean
-}
-
-/** Structural view of the host Loader (entry enumeration is all the registry needs). */
-export interface LoaderView {
-  entries(): Iterable<LoaderEntryView>
-}
-
-/** Dependencies injected by the assembly layer. */
-export interface WebPluginRegistryDeps {
-  /** Host root context; used only to subscribe `internal/plugin` for rescans. */
-  ctx: Context
-  /** The host Loader owning the plugin entries. */
-  loader: LoaderView
-  /**
-   * Resolve a package specifier to its package.json absolute path (assembly
-   * passes `createRequire(...).resolve(`${name}/package.json`)`); injected so
-   * the registry makes no module-resolution assumptions of its own.
-   */
-  resolvePkgJson: (name: string) => string
-  /** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
-  onError: (err: Error) => void
-  /**
-   * Dev-mode bundle watching: stat-poll every scanned row's client bundle
-   * with an explicit stat baseline (polling by design: network mounts deliver
-   * no inotify events) and re-hash + notify onRebuilt subscribers on change.
-   * Absent = no watching (prod composition).
-   */
-  watch?: {
-    /** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
-    intervalMs?: number
-  }
-}
-
-/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
-interface DshClientDeclaration {
-  inject?: string[]
-  platform: string
-  /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
-  immediately?: boolean
-}
-
-interface WebPluginRecord {
-  entry: WebBootEntry
-  clientPath: string
-}
-
-interface WatchedBundle {
-  path: string
-  mtimeMs: number
-  size: number
-  dirty: boolean
-}
-
-/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
-function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined {
-  if (value === undefined) return undefined
-  if (typeof value !== 'object' || value === null) {
-    throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`)
-  }
-  const decl = value as Record<string, unknown>
-  if (typeof decl.platform !== 'string') {
-    throw new Error(`web-plugins: ${name} dshClient.platform must be a string`)
-  }
-  if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
-    throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`)
-  }
-  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
-    throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`)
-  }
-  return {
-    platform: decl.platform,
-    ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
-    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
-  }
-}
-
-/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
-function clientExportOf(name: string, exportsField: unknown): string | undefined {
-  if (typeof exportsField !== 'object' || exportsField === null) return undefined
-  const client = (exportsField as Record<string, unknown>)['./client']
-  if (client === undefined) return undefined
-  if (typeof client === 'string') return client
-  if (typeof client === 'object' && client !== null) {
-    const fallback = (client as Record<string, unknown>).default
-    if (typeof fallback === 'string') return fallback
-  }
-  throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`)
-}
-
-/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
-function shortHash(input: string | Buffer): string {
-  return createHash('sha1').update(input).digest('hex').slice(0, 12)
-}
-
-/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
-function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry {
-  return {
-    id,
-    url: `/plugins/${id}/client.js?rev=${rev}`,
-    rev,
-    ...(inject !== undefined ? { inject } : {}),
-    ...(immediately ? { immediately: true } : {}),
-  }
-}
-
-/** Compose the graph value from the current table. */
-function composeGraph(table: Map<string, WebPluginRecord>): WebBootGraph {
-  const entries = [...table.values()].map(record => record.entry)
-  return { rev: shortHash(JSON.stringify(entries)), entries }
-}
-
-/**
- * Build the web plugin registry: scan once synchronously (a malformed
- * declaration, an unbuilt bundle, or an invalid watch interval throws here —
- * load-time fail loud), then rescan on `internal/plugin`, microtask-debounced
- * (failures go to `deps.onError`). With `deps.watch`, every scanned bundle
- * file is stat-polled and a content change re-hashes the row and notifies
- * `onRebuilt` subscribers.
- * @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}).
- * @returns the registry handle.
- */
-export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry {
-  const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500
-  if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) {
-    throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
-  }
-
-  const stageWatches = (
-    candidateTable: Map<string, WebPluginRecord>,
-    currentWatches: Map<string, WatchedBundle>,
-  ): Map<string, WatchedBundle> => {
-    const candidateWatches = new Map<string, WatchedBundle>()
-    if (watchInterval === undefined) return candidateWatches
-    for (const [id, record] of candidateTable) {
-      const current = currentWatches.get(id)
-      if (current?.path === record.clientPath) {
-        candidateWatches.set(id, { ...current })
-        continue
-      }
-      const baseline = statSync(record.clientPath)
-      candidateWatches.set(id, {
-        path: record.clientPath,
-        mtimeMs: baseline.mtimeMs,
-        size: baseline.size,
-        dirty: false,
-      })
-    }
-    return candidateWatches
-  }
-
-  let table = scan(deps)
-  let graph = composeGraph(table)
-  let watched = stageWatches(table, new Map())
-  const rebuildListeners = new Set<(id: string, rev: string) => void>()
-
-  const rebuilt = (id: string): string | undefined => {
-    const record = table.get(id)
-    if (record === undefined) return undefined
-    const rev = shortHash(readFileSync(record.clientPath))
-    record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
-    graph = composeGraph(table)
-    return rev
-  }
-
-  // Dev bundle watch: capture every row's baseline synchronously before the
-  // registry is returned, then poll those baselines. fs.watchFile establishes
-  // its first baseline asynchronously, so an immediate rebuild can otherwise
-  // become the baseline and disappear without an observed delta.
-  const pollWatches = (): void => {
-    for (const [id, watch] of watched) {
-      let current: Stats
-      try {
-        current = statSync(watch.path)
-      } catch (error) {
-        const code = (error as NodeJS.ErrnoException).code
-        if (code === 'ENOENT') {
-          watch.dirty = true
-          continue
-        }
-        deps.onError(error instanceof Error ? error : new Error(String(error)))
-        continue
-      }
-      if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
-      const before = table.get(id)?.entry.rev
-      let rev: string | undefined
-      try {
-        rev = rebuilt(id)
-      } catch (error) {
-        const code = (error as NodeJS.ErrnoException).code
-        if (code === 'ENOENT') {
-          watch.dirty = true
-          continue
-        }
-        watch.mtimeMs = current.mtimeMs
-        watch.size = current.size
-        deps.onError(error instanceof Error ? error : new Error(String(error)))
-        continue
-      }
-      watch.mtimeMs = current.mtimeMs
-      watch.size = current.size
-      watch.dirty = false
-      if (rev === undefined || rev === before) continue
-      for (const notify of rebuildListeners) {
-        // A throwing subscriber must not skip later subscribers or escape the
-        // polling callback into the process event loop.
-        try {
-          notify(id, rev)
-        } catch (error) {
-          deps.onError(error instanceof Error ? error : new Error(String(error)))
-        }
-      }
-    }
-  }
-  const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval)
-  watchTimer?.unref()
-
-  let pending = false
-  const unsubscribe = deps.ctx.on('internal/plugin', () => {
-    if (pending) return
-    pending = true
-    queueMicrotask(() => {
-      pending = false
-      try {
-        const candidateTable = scan(deps)
-        const candidateGraph = composeGraph(candidateTable)
-        const candidateWatches = stageWatches(candidateTable, watched)
-        table = candidateTable
-        graph = candidateGraph
-        watched = candidateWatches
-      } catch (error) {
-        // Keep serving the previous graph: a mid-flight rescan failure must not
-        // take down the boot manifest for plugins that were fine.
-        deps.onError(error instanceof Error ? error : new Error(String(error)))
-      }
-    })
-  })
-
-  return {
-    graph: () => graph,
-    clientPath: id => table.get(id)?.clientPath,
-    rebuilt,
-    onRebuilt: (listener) => {
-      rebuildListeners.add(listener)
-      return () => { rebuildListeners.delete(listener) }
-    },
-    dispose: () => {
-      unsubscribe()
-      if (watchTimer !== undefined) clearInterval(watchTimer)
-      watched.clear()
-      rebuildListeners.clear()
-    },
-  }
-}
-
-/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */
-function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
-  const table = new Map<string, WebPluginRecord>()
-  for (const entry of deps.loader.entries()) {
-    if (entry.fiber === undefined || entry.disabled) continue
-    const name = entry.options.name
-    if (table.has(name)) continue
-    const pkgPath = deps.resolvePkgJson(name)
-    const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
-    const decl = parseDshClient(name, pkg.dshClient)
-    if (decl === undefined || decl.platform !== 'web') continue
-    const clientRel = clientExportOf(name, pkg.exports)
-    if (clientRel === undefined) {
-      throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
-    }
-    const clientPath = join(dirname(pkgPath), clientRel)
-    const rev = shortHash(readFileSync(clientPath))
-    table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath })
-  }
-  return table
-}

+ 0 - 50
packages/host/webserver/tests/invariant.spec.ts

@@ -1,50 +0,0 @@
-/**
- * Webserver invariant companion: the boot-graph consistency audit — every
- * fetch-arrival graph row must resolve a clientPath, checked on fiber
- * lifecycle events against the assembly-published 'webPlugins' context key.
- */
-import { Context } from 'cordis'
-import { describe, expect, it } from 'vitest'
-import InvariantService from '@deepseek-ai/dsh-invariants'
-import * as WebserverInvariant from '../src/invariant.ts'
-
-interface RegistryStub {
-  graph(): { entries: { id: string; url: string }[] }
-  clientPath(id: string): string | undefined
-}
-
-async function setup(registry?: RegistryStub): Promise<Context> {
-  const ctx = new Context()
-  await ctx.plugin(InvariantService, { enabled: true })
-  await ctx.plugin(WebserverInvariant).await()
-  if (registry !== undefined) ctx.reflect.provide('webPlugins', registry)
-  return ctx
-}
-
-/** Fire the audit trigger directly (same technique as the scope invariant
- *  spec): a synchronous emit propagates the fail() throw to the caller. */
-function trigger(ctx: Context): void {
-  ;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber)
-}
-
-describe('webserver manifest invariant', () => {
-  it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => {
-    const bare = await setup()
-    expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
-
-    const consistent = await setup({
-      graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }),
-      clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined,
-    })
-    expect(() => { trigger(consistent) }).not.toThrow()
-  })
-
-  it('throws on a graph row whose bundle path no longer resolves', async () => {
-    const ctx = await setup({
-      graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }),
-      clientPath: () => undefined,
-    })
-    expect(() => { trigger(ctx) })
-      .toThrow(/graph row "ghost".*resolves no client bundle path/)
-  })
-})

+ 0 - 347
packages/host/webserver/tests/web-plugins.spec.ts

@@ -1,347 +0,0 @@
-import {
-  mkdirSync,
-  mkdtempSync,
-  statSync,
-  type PathLike,
-  type Stats,
-  unlinkSync,
-  utimesSync,
-  writeFileSync,
-} from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { Context } from 'cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
-import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
-
-const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined }))
-
-vi.mock('node:fs', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('node:fs')>()
-  return {
-    ...actual,
-    statSync: (path: PathLike): Stats => {
-      if (String(path) === fsControl.failNextStatPath) {
-        fsControl.failNextStatPath = undefined
-        throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' })
-      }
-      return actual.statSync(path)
-    },
-  }
-})
-
-afterEach(() => {
-  fsControl.failNextStatPath = undefined
-  vi.useRealTimers()
-})
-
-/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
-function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
-  const dir = join(root, name.replaceAll('/', '__'))
-  mkdirSync(join(dir, 'lib'), { recursive: true })
-  writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg }))
-  if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`)
-  return join(dir, 'package.json')
-}
-
-const webDecl = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
-  dshClient: { inject: [], platform: 'web', ...extra },
-  exports: { '.': './lib/index.js', './client': './lib/client.js' },
-})
-
-interface Fixture {
-  deps: WebPluginRegistryDeps
-  entries: LoaderEntryView[]
-  errors: Error[]
-  ctx: Context
-  root: string
-}
-
-function makeDeps(
-  specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
-): Fixture {
-  const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
-  const paths = new Map<string, string>()
-  const entries: LoaderEntryView[] = specs.map((spec) => {
-    paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true))
-    return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false }
-  })
-  const ctx = new Context()
-  const errors: Error[] = []
-  const deps: WebPluginRegistryDeps = {
-    ctx,
-    loader: { entries: () => entries },
-    resolvePkgJson: (name) => {
-      const path = paths.get(name)
-      if (path === undefined) throw new Error(`unresolvable ${name}`)
-      return path
-    },
-    onError: err => void errors.push(err),
-  }
-  return { deps, entries, errors, ctx, root }
-}
-
-describe('createHostWebPluginRegistry', () => {
-  it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => {
-    const { deps } = makeDeps([
-      { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
-      { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
-      { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
-    ])
-    const registry = createHostWebPluginRegistry(deps)
-    const graph = registry.graph()
-    expect(graph.rev).toMatch(/^[0-9a-f]{12}$/)
-    const connection = graph.entries[0]
-    expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection')
-    expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/)
-    expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`)
-    expect(connection?.immediately).toBe(true)
-    const layout = graph.entries[1]
-    expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout')
-    expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime'])
-    expect(layout?.immediately).toBeUndefined()
-    expect(graph.entries).toHaveLength(2)
-    expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/)
-    expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
-    registry.dispose()
-  })
-
-  it('skips entries that are unloaded, disabled, or declare another platform', () => {
-    const { deps } = makeDeps([
-      { name: 'not-loaded', pkg: webDecl(), loaded: false },
-      { name: 'disabled', pkg: webDecl(), disabled: true },
-      { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
-    ])
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.graph().entries).toEqual([])
-    registry.dispose()
-  })
-
-  it('fails loud at build time on a dshClient declaration without a "./client" export', () => {
-    const { deps } = makeDeps([
-      { name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } },
-    ])
-    expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
-  })
-
-  it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => {
-    const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }])
-    expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/)
-  })
-
-  it('fails loud on malformed declaration fields', () => {
-    for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
-      const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
-      expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/)
-    }
-  })
-
-  it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => {
-    const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }])
-    const registry = createHostWebPluginRegistry(deps)
-    const before = registry.graph()
-    const beforeRow = before.entries.find(e => e.id === 'hot')
-    writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents')
-    const rev = registry.rebuilt('hot')
-    expect(rev).toMatch(/^[0-9a-f]{12}$/)
-    expect(rev).not.toBe(beforeRow?.rev)
-    const after = registry.graph()
-    const afterRow = after.entries.find(e => e.id === 'hot')
-    expect(afterRow?.rev).toBe(rev)
-    expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`)
-    expect(afterRow?.immediately).toBe(true)
-    expect(after.rev).not.toBe(before.rev)
-    // Unknown ids are not rebuildable.
-    expect(registry.rebuilt('nope')).toBeUndefined()
-    registry.dispose()
-  })
-
-  it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => {
-    const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
-    deps.watch = { intervalMs: 20 }
-    const registry = createHostWebPluginRegistry(deps)
-    const before = registry.graph().entries[0]?.rev
-    const rebuilds: { id: string; rev: string }[] = []
-    registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
-
-    writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents')
-    await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 })
-    expect(rebuilds[0]?.id).toBe('watched')
-    expect(rebuilds[0]?.rev).not.toBe(before)
-    expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
-
-    registry.dispose()
-    writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents')
-    await new Promise((resolve) => { setTimeout(resolve, 100) })
-    expect(rebuilds).toHaveLength(1)
-  })
-
-  it('watch mode: a failed rescan baseline preserves the published table and graph', async () => {
-    const { deps, entries, errors, ctx, root } = makeDeps([
-      { name: 'stable', pkg: webDecl() },
-      { name: 'late', pkg: webDecl(), loaded: false },
-    ])
-    deps.watch = { intervalMs: 1_000 }
-    const registry = createHostWebPluginRegistry(deps)
-    const before = registry.graph()
-
-    ;(entries[1] as { fiber?: unknown }).fiber = {}
-    fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js')
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-
-    expect(errors[0]?.message).toContain('staged bundle missing')
-    expect(registry.graph()).toBe(before)
-    expect(registry.clientPath('late')).toBeUndefined()
-
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-    expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late'])
-    registry.dispose()
-  })
-
-  it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => {
-    vi.useFakeTimers()
-    const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
-    const bundle = join(root, 'watched', 'lib', 'client.js')
-    const fixedTime = new Date(1_600_000_000_000)
-    utimesSync(bundle, fixedTime, fixedTime)
-    deps.watch = { intervalMs: 20 }
-    const registry = createHostWebPluginRegistry(deps)
-    const baseline = statSync(bundle)
-    const rebuilds: { id: string; rev: string }[] = []
-    registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
-
-    unlinkSync(bundle)
-    await vi.advanceTimersByTimeAsync(20)
-    writeFileSync(bundle, 'x'.repeat(baseline.size))
-    utimesSync(bundle, fixedTime, fixedTime)
-    const restored = statSync(bundle)
-    expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
-      mtimeMs: baseline.mtimeMs,
-      size: baseline.size,
-    })
-    await vi.advanceTimersByTimeAsync(20)
-
-    expect(rebuilds).toHaveLength(1)
-    expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
-    registry.dispose()
-  })
-
-  it('rejects a non-positive or non-integer watch interval at build time', () => {
-    for (const intervalMs of [0, -5, 1.5]) {
-      const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])
-      deps.watch = { intervalMs }
-      expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/)
-    }
-  })
-
-  it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => {
-    const { deps, entries, errors, ctx } = makeDeps([
-      { name: 'late-loader', pkg: webDecl(), loaded: false },
-    ])
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.graph().entries).toEqual([])
-
-    // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
-    ;(entries[0] as { fiber?: unknown }).fiber = {}
-    ctx.emit('internal/plugin', ctx.fiber)
-    ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
-    await Promise.resolve()
-    expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
-
-    // A failing rescan reports the error and keeps serving the previous graph.
-    entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-    expect(errors).toHaveLength(1)
-    expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader'])
-
-    // After dispose, further fiber events no longer rescan.
-    registry.dispose()
-    entries.pop()
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-    expect(errors).toHaveLength(1)
-  })
-})
-
-describe('injectBootManifest', () => {
-  it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
-    const html = '<html><head><script src="app.js"></script></head><body></body></html>'
-    const out = injectBootManifest(html, {
-      rev: 'r1',
-      entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
-    })
-    expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
-    expect(out).not.toContain('</script><script>alert(1)')
-    expect(out).toContain('\\u003c/script')
-  })
-
-  it('prepends when the page has no <head>', () => {
-    const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
-    expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
-  })
-})
-
-describe('clientExportOf shapes (through the registry build)', () => {
-  it('accepts the conditional {types, default} export form', () => {
-    const { deps } = makeDeps([{
-      name: 'conditional',
-      pkg: {
-        dshClient: { platform: 'web' },
-        exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
-      },
-    }])
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
-    registry.dispose()
-  })
-
-  it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
-    for (const exportsField of [
-      { './client': { types: './x.d.ts' } },
-      { './client': ['./a.js'] },
-    ]) {
-      const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
-      expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
-    }
-    // Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
-    const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
-    expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
-  })
-
-  it('skips duplicate loader entries for the same package name (first wins)', () => {
-    const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
-    const first = entries[0] as LoaderEntryView
-    entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
-    void first
-    const registry = createHostWebPluginRegistry(deps)
-    expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
-    registry.dispose()
-  })
-
-  it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
-    // client: null → the object-form branch's null guard.
-    const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
-    expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
-
-    // Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
-    const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
-    const registry = createHostWebPluginRegistry(deps)
-    entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
-    const original = deps.resolvePkgJson
-    deps.resolvePkgJson = (name) => {
-
-      if (name === 'ghost-two') throw 'string failure'
-      return original(name)
-    }
-    ctx.emit('internal/plugin', ctx.fiber)
-    await Promise.resolve()
-    expect(errors[0]).toBeInstanceOf(Error)
-    expect(String(errors[0])).toContain('string failure')
-    registry.dispose()
-  })
-
-})

+ 0 - 400
packages/host/webserver/tests/webserver.spec.ts

@@ -1,400 +0,0 @@
-import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
-import { Server as NetServer } from 'node:net'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { startWebServer, type RunningWebServer } from '../src/index.ts'
-
-/** dist fixture: index.html + one asset of each MIME class + a subdir. */
-function makeDist(): { distIndex: string; distRoot: string } {
-  const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
-  writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
-  writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
-  writeFileSync(join(distRoot, 'app.css'), 'body{}')
-  writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
-  writeFileSync(join(distRoot, 'data.json'), '{}')
-  writeFileSync(join(distRoot, 'app.js.map'), '{}')
-  writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
-  mkdirSync(join(distRoot, 'sub'))
-  writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
-  return { distIndex: join(distRoot, 'index.html'), distRoot }
-}
-
-const echoingApi = {
-  fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
-    const req = input instanceof Request ? input : new Request(input, init)
-    if (req.url.endsWith('/api/echo')) {
-      return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
-    }
-    if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
-    if (req.url.endsWith('/api/big')) {
-      // Chunks far above any socket highWaterMark force res.write to return false.
-      const big = new Uint8Array(4 * 1024 * 1024).fill(65)
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(big)
-          controller.enqueue(big)
-          controller.close()
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
-    }
-    if (req.url.endsWith('/api/sse')) {
-      const encoder = new TextEncoder()
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(encoder.encode('data: one\n\n'))
-          controller.enqueue(encoder.encode('data: two\n\n'))
-          controller.close()
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    if (req.url.endsWith('/api/throw-string')) {
-      // Non-Error rejection: the guard must wrap it for onError.
-      throw 'string failure'
-    }
-    if (req.url.endsWith('/api/explode-mid-stream')) {
-      // Headers go out with the first chunk, then the source errors: the
-      // guard's headersSent leg must destroy the socket, not writeHead again.
-      // The error is deferred a tick so the 200 + first chunk actually flush
-      // to the client before the teardown.
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          controller.enqueue(new TextEncoder().encode('data: first\n\n'))
-          setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    if (req.url.endsWith('/api/abort-probe')) {
-      // Endless SSE that only ends when the request signal aborts.
-      const stream = new ReadableStream<Uint8Array>({
-        start(controller) {
-          req.signal.addEventListener('abort', () => {
-            try {
-              controller.close()
-            } catch { /* already closed by teardown: nothing else can reach this */ }
-          }, { once: true })
-          controller.enqueue(new TextEncoder().encode('data: open\n\n'))
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
-    }
-    return new Response('nope', { status: 404 })
-  },
-}
-
-let server: RunningWebServer | undefined
-
-afterEach(async () => {
-  await server?.close()
-  server = undefined
-})
-
-async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
-  const { distIndex } = makeDist()
-  server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
-  return `http://127.0.0.1:${String(server.port)}`
-}
-
-describe('startWebServer', () => {
-  it('reports the listening port and closes idempotently', async () => {
-    const { distIndex } = makeDist()
-    server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
-    expect(server.port).toBeGreaterThan(0)
-    const first = server.close()
-    const second = server.close()
-    expect(second).toBe(first)
-    await first
-    server = undefined
-  })
-
-  it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
-    const { distIndex } = makeDist()
-    const port = 3080
-    const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
-      this: NetServer, ...args: unknown[]
-    ): NetServer {
-      const callback = args.at(-1)
-      if (typeof callback !== 'function') throw new TypeError('listen callback missing')
-      queueMicrotask(callback as () => void)
-      return this
-    })
-    const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
-    try {
-      const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
-      expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
-      await inertServer.close()
-    } finally {
-      address.mockRestore()
-      listen.mockRestore()
-    }
-  })
-
-  it('rejects when the port is already taken', async () => {
-    const { distIndex } = makeDist()
-    server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
-    const { port } = server
-    await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
-      .rejects.toMatchObject({ code: 'EADDRINUSE' })
-  })
-})
-
-describe.skipIf(process.platform === 'win32')('static serving', () => {
-  it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
-    const base = await boot()
-    const index = await fetch(`${base}/`)
-    expect(index.status).toBe(200)
-    expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
-    expect(await index.text()).toBe('<html>INDEX</html>')
-
-    expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
-    expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
-    expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
-    expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
-    expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
-    expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
-    expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
-
-    const miss = await fetch(`${base}/routes/deep/link`)
-    expect(miss.status).toBe(200)
-    expect(await miss.text()).toBe('<html>INDEX</html>')
-  })
-
-  it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
-    const base = await boot()
-    // %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
-    // encoded slash keeps the segment intact until the server's decodeURIComponent.
-    const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
-    expect(traversal.status).toBe(403)
-    const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
-    expect(put.status).toBe(405)
-  })
-
-  it('answers HEAD like GET (no 405)', async () => {
-    const base = await boot()
-    const head = await fetch(`${base}/`, { method: 'HEAD' })
-    expect(head.status).toBe(200)
-  })
-})
-
-describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
-  const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
-  const graphValue = {
-    rev: 'graphrev00001',
-    entries: [
-      { id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
-      { id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
-    ],
-  }
-
-  /** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
-  interface RebuiltHarness {
-    notify: (id: string, rev: string) => void
-    unsubscribed: boolean
-  }
-
-  async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
-    const { distIndex, distRoot } = makeDist()
-    writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
-    const webPlugins = {
-      graph: () => graphValue,
-      clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
-      onRebuilt: (listener: (id: string, rev: string) => void) => {
-        if (harness !== undefined) harness.notify = listener
-        return () => {
-          if (harness !== undefined) harness.unsubscribed = true
-        }
-      },
-    }
-    server = await startWebServer(
-      { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
-    )
-    return `http://127.0.0.1:${String(server.port)}`
-  }
-
-  it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
-    const base = await bootWithPlugins()
-    const index = await (await fetch(`${base}/`)).text()
-    expect(index).toContain('window.__DSH_BOOT__')
-    const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
-    expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
-
-    const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
-    expect(fallback).toContain('window.__DSH_BOOT__')
-    const direct = await (await fetch(`${base}/index.html`)).text()
-    expect(direct).toContain('window.__DSH_BOOT__')
-
-    expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
-  })
-
-  it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
-    const base = await bootWithPlugins()
-    const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
-    expect(bundle.status).toBe(200)
-    expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
-    expect(bundle.headers.get('cache-control')).toBe('no-cache')
-    expect(await bundle.text()).toContain('DSHClientProxy')
-
-    expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
-  })
-
-  it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
-    const { distIndex } = makeDist()
-    const webPlugins = {
-      graph: () => graphValue,
-      clientPath: () => '/nonexistent/lib/client.js',
-      onRebuilt: () => () => undefined,
-    }
-    server = await startWebServer(
-      { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
-    )
-    const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
-    expect(res.status).toBe(404)
-  })
-
-  it('keeps all plugin surfaces off without the webPlugins option', async () => {
-    const base = await boot()
-    expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
-    // No plugin routes: fall through to static SPA fallback semantics.
-    const res = await fetch(`${base}/plugins/x/client.js`)
-    expect(res.status).toBe(200)
-    expect(await res.text()).toBe('<html>INDEX</html>')
-    const events = await fetch(`${base}/plugins/events`)
-    expect(await events.text()).toBe('<html>INDEX</html>')
-  })
-
-  it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
-    const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
-    const base = await bootWithPlugins(harness)
-    const events = await fetch(`${base}/plugins/events`)
-    expect(events.status).toBe(200)
-    expect(events.headers.get('content-type')).toBe('text/event-stream')
-    const reader = events.body?.getReader()
-    const decoder = new TextDecoder()
-    let buffer = ''
-    async function readUntil(marker: string): Promise<void> {
-      while (!buffer.includes(marker)) {
-        const chunk = await reader?.read()
-        if (chunk?.done !== false) throw new Error('SSE stream ended early')
-        buffer += decoder.decode(chunk.value, { stream: true })
-      }
-    }
-    await readUntil('"type":"graph"')
-    expect(buffer).toContain(': connected')
-    const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
-    expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
-
-    // The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
-    harness.notify(FETCH_ID, 'cccc1111dddd')
-    await readUntil('"type":"rebuilt"')
-    expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
-    await reader?.cancel()
-
-    // Shutdown unsubscribes the relay (no broadcast into a closed channel).
-    await server?.close()
-    server = undefined
-    expect(harness.unsubscribed).toBe(true)
-  })
-})
-
-describe('request-handling guard (one bad request must not kill the process)', () => {
-  it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    for (const path of ['/%', '/%c0', '/%zz%']) {
-      expect((await fetch(`${base}${path}`)).status).toBe(400)
-    }
-    expect(errors.length).toBe(3)
-    expect(errors[0]?.name).toBe('URIError')
-    // The barrage left the server serving.
-    expect((await fetch(`${base}/`)).status).toBe(200)
-  })
-
-  it('wraps a non-Error throw for onError and still answers 400', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
-    expect(errors[0]).toBeInstanceOf(Error)
-    expect(errors[0]?.message).toBe('string failure')
-  })
-
-  it('destroys the socket when the failure lands after headers went out', async () => {
-    const errors: Error[] = []
-    const base = await boot(err => errors.push(err))
-    const response = await fetch(`${base}/api/explode-mid-stream`)
-    expect(response.status).toBe(200) // headers made it out before the explosion
-    await expect(response.text()).rejects.toThrow() // then the socket is torn down
-    expect(errors.length).toBe(1)
-    expect((await fetch(`${base}/`)).status).toBe(200)
-  })
-})
-
-describe('/api bridge', () => {
-  it('forwards method, headers, and body; relays status and body back', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/echo`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
-      body: JSON.stringify({ n: 1 }),
-    })
-    expect(response.status).toBe(200)
-    expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
-  })
-
-  it('relays a bodyless response', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/empty`, { method: 'POST' })
-    expect(response.status).toBe(204)
-    expect(await response.text()).toBe('')
-  })
-
-  it('streams SSE frames through chunk by chunk', async () => {
-    const base = await boot()
-    const response = await fetch(`${base}/api/sse`)
-    expect(response.headers.get('content-type')).toBe('text/event-stream')
-    expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
-  })
-
-  it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
-    // 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
-    // and the bridge parks on 'drain'; reading the body to completion proves
-    // the loop resumed instead of dropping the remainder.
-    const base = await boot()
-    const response = await fetch(`${base}/api/big`)
-    const body = new Uint8Array(await response.arrayBuffer())
-    expect(body.length).toBe(8 * 1024 * 1024)
-    expect(body[0]).toBe(65)
-    expect(body[body.length - 1]).toBe(65)
-  })
-
-  it('releases a drain wait when the client disconnects mid-chunk', async () => {
-    // The 'close' leg of the drain race: abort while the socket buffer is
-    // still full so the parked write wakes via 'close', not 'drain'.
-    const base = await boot()
-    const ac = new AbortController()
-    const response = await fetch(`${base}/api/big`, { signal: ac.signal })
-    const reader = response.body?.getReader()
-    const first = await reader?.read()
-    expect(first?.value?.length).toBeGreaterThan(0)
-    ac.abort()
-    // afterEach close() completing is the leak assertion, same as abort-probe.
-    await new Promise((resolve) => { setTimeout(resolve, 50) })
-  })
-
-  it('aborts the bridged request when the client disconnects mid-SSE', async () => {
-    const base = await boot()
-    const ac = new AbortController()
-    const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
-    const reader = response.body?.getReader()
-    expect(reader).toBeDefined()
-    const first = await reader?.read()
-    expect(new TextDecoder().decode(first?.value)).toContain('open')
-    ac.abort()
-    // server-side abort propagation has no client-observable handshake beyond
-    // the closed connection; close() would hang on a leaked live SSE socket,
-    // so afterEach completing IS the assertion that the bridge released it.
-    await new Promise((resolve) => { setTimeout(resolve, 50) })
-  })
-})

+ 3 - 0
packages/host/webserver/tsconfig.json

@@ -11,6 +11,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../support/invariants"
     }

+ 155 - 8
pnpm-lock.yaml

@@ -98,9 +98,27 @@ importers:
 
   apps/cli:
     dependencies:
+      '@cordisjs/plugin-include':
+        specifier: workspace:*
+        version: link:../../vendor/include
+      '@cordisjs/plugin-loader':
+        specifier: workspace:*
+        version: link:../../vendor/loader
+      '@cordisjs/plugin-timer':
+        specifier: workspace:*
+        version: link:../../vendor/timer
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../packages/core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../packages/core/agent-loop
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../../packages/ui/app-boot
+      '@deepseek-ai/dsh-bash-local':
+        specifier: workspace:^
+        version: link:../../packages/bash/bash-local
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../packages/client/connection
@@ -110,6 +128,9 @@ importers:
       '@deepseek-ai/dsh-client-i18n':
         specifier: workspace:^
         version: link:../../packages/client/i18n
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../../packages/client/modules
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../../packages/client/runtime
@@ -131,9 +152,18 @@ importers:
       '@deepseek-ai/dsh-client-ui-trajectory':
         specifier: workspace:^
         version: link:../../packages/client/ui-trajectory
+      '@deepseek-ai/dsh-compact-basic':
+        specifier: workspace:^
+        version: link:../../packages/compact/compact-basic
       '@deepseek-ai/dsh-frontend':
         specifier: workspace:^
         version: link:../web
+      '@deepseek-ai/dsh-fs-local':
+        specifier: workspace:^
+        version: link:../../packages/fs/fs-local
+      '@deepseek-ai/dsh-fs-policy':
+        specifier: workspace:^
+        version: link:../../packages/fs/fs-policy
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../packages/host/apiproxy
@@ -143,18 +173,109 @@ importers:
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../packages/host/webserver
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../packages/llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:^
+        version: link:../../packages/llm/llm-deepseek
       '@deepseek-ai/dsh-paths':
         specifier: workspace:^
         version: link:../../packages/util/paths
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../packages/core/session
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:^
+        version: link:../../packages/session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../packages/session-title/session-title
+      '@deepseek-ai/dsh-session-title-first-message-llm':
+        specifier: workspace:^
+        version: link:../../packages/session-title/session-title-first-message-llm
+      '@deepseek-ai/dsh-skill':
+        specifier: workspace:^
+        version: link:../../packages/skill/skill
+      '@deepseek-ai/dsh-skill-local':
+        specifier: workspace:^
+        version: link:../../packages/skill/skill-local
+      '@deepseek-ai/dsh-spill-local':
+        specifier: workspace:^
+        version: link:../../packages/spill/spill-local
+      '@deepseek-ai/dsh-spill-policy':
+        specifier: workspace:^
+        version: link:../../packages/spill/spill-policy
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../packages/subagent/subagent
+      '@deepseek-ai/dsh-subagent-fork':
+        specifier: workspace:^
+        version: link:../../packages/subagent/subagent-fork
+      '@deepseek-ai/dsh-subagent-spawn':
+        specifier: workspace:^
+        version: link:../../packages/subagent/subagent-spawn
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../packages/core/system-prompt
+      '@deepseek-ai/dsh-tasks':
+        specifier: workspace:^
+        version: link:../../packages/tasks/tasks
+      '@deepseek-ai/dsh-timeout-policy':
+        specifier: workspace:^
+        version: link:../../packages/timeout/timeout-policy
+      '@deepseek-ai/dsh-token-meter':
+        specifier: workspace:^
+        version: link:../../packages/llm/token-meter
+      '@deepseek-ai/dsh-tool-bash':
+        specifier: workspace:^
+        version: link:../../packages/bash/tool-bash
+      '@deepseek-ai/dsh-tool-fs':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-fs
+      '@deepseek-ai/dsh-tool-fs-search':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-fs-search
+      '@deepseek-ai/dsh-tool-skill':
+        specifier: workspace:^
+        version: link:../../packages/skill/tool-skill
+      '@deepseek-ai/dsh-tool-subagent':
+        specifier: workspace:^
+        version: link:../../packages/subagent/tool-subagent
+      '@deepseek-ai/dsh-tool-tasks':
+        specifier: workspace:^
+        version: link:../../packages/tasks/tool-tasks
+      '@deepseek-ai/dsh-tool-todo':
+        specifier: workspace:^
+        version: link:../../packages/todo/tool-todo
+      '@deepseek-ai/dsh-tool-workflow':
+        specifier: workspace:^
+        version: link:../../packages/workflow/tool-workflow
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../packages/core/tools
       '@deepseek-ai/dsh-tui':
         specifier: workspace:^
         version: link:../../packages/ui/tui
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../../packages/ui/user-interaction
+      '@deepseek-ai/dsh-workflow-workerthread':
+        specifier: workspace:^
+        version: link:../../packages/workflow/workflow-workerthread
+      '@deepseek-ai/dsh-workspace-context':
+        specifier: workspace:^
+        version: link:../../packages/context/workspace-context
       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@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+      js-yaml:
+        specifier: ^4.2.0
+        version: 4.2.0
+    devDependencies:
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
 
   apps/web:
     dependencies:
@@ -180,9 +301,6 @@ importers:
       '@deepseek-ai/dsh-client-web-react':
         specifier: workspace:^
         version: link:../../packages/client/web-react
-      '@deepseek-ai/dsh-host-webserver':
-        specifier: workspace:^
-        version: link:../../packages/host/webserver
       '@types/node':
         specifier: ^22.0.0
         version: 22.20.0
@@ -541,6 +659,9 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
     devDependencies:
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -549,6 +670,10 @@ importers:
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
   packages/client/hmr:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
     devDependencies:
       '@cordisjs/plugin-loader':
         specifier: workspace:^
@@ -556,6 +681,9 @@ importers:
       '@deepseek-ai/dsh-client-modules':
         specifier: workspace:^
         version: link:../modules
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -578,12 +706,18 @@ importers:
 
   packages/client/modules:
     devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-host-webserver':
+        specifier: workspace:^
+        version: link:../../host/webserver
       '@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)
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
 
   packages/client/runtime:
     dependencies:
@@ -2051,6 +2185,9 @@ importers:
 
   packages/host/apiproxy:
     dependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
@@ -2060,6 +2197,12 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-persistence':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
@@ -2069,6 +2212,9 @@ importers:
       '@deepseek-ai/dsh-user-interaction':
         specifier: workspace:^
         version: link:../../ui/user-interaction
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
       zod:
         specifier: ^4.4.3
         version: 4.4.3
@@ -2118,9 +2264,6 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
-      '@deepseek-ai/dsh-session-persistence':
-        specifier: workspace:^
-        version: link:../../session-persistence/session-persistence
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
@@ -2208,6 +2351,10 @@ importers:
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
 
   packages/host/webserver:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -214,6 +214,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
   InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
   LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
+  WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
+  WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts',
   ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
   Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
   TuiOverlayRequest: 'service-local extension contract is owned by packages/ui/tui/README.md',

+ 16 - 0
scripts/gen-doc-graphs.ts

@@ -380,6 +380,22 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['spill-policy'],
     note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
   },
+  {
+    key: 'httpServer',
+    pkg: 'webserver',
+    title: 'HTTP route registration',
+    mode: 'core',
+    consumers: ['connection', 'modules', 'hmr'],
+    note: 'Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes.',
+  },
+  {
+    key: 'clientModuleHost',
+    pkg: 'modules',
+    title: 'Client plugin graph host',
+    mode: 'core',
+    consumers: ['hmr'],
+    note: 'Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
+  },
   {
     key: 'workflows',
     pkg: 'workflow',

+ 14 - 1
vitest.config.ts

@@ -108,8 +108,21 @@ export default defineConfig({
         'packages/client/ui-layout/src/*',
         'packages/client/web/src/*',
         'packages/host/webserver/src/*',
-        'packages/client/modules/src/loader.ts',
+        'packages/client/modules/src/client/system.ts',
         'packages/client/hmr/src/client/index.ts',
+        // Web config-tree boot round: the new host-side web-transport halves
+        // whose remaining branches need real-composition/process harnesses.
+        // TODO(gui): cover and remove with the client test lane above.
+        'packages/client/modules/src/index.ts',
+        'packages/client/modules/src/invariant.ts',
+        'packages/client/modules/src/client/index.ts',
+        'packages/client/modules/src/client/manifest.ts',
+        'packages/client/hmr/src/index.ts',
+        'packages/client/hmr/src/invariant.ts',
+        'packages/client/connection/src/index.ts',
+        'packages/client/connection/src/http-bridge.ts',
+        'packages/host/apiproxy/src/index.ts',
+        'packages/host/apiproxy/src/invariant.ts',
         ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
         ...windowsCoverageExclusions,
       ],