Преглед изворни кода

Merge pull request #4189 from deepseek-harness/worktree/client-modules-live

feat(web): apply client plugin changes without reloading
Yichen Jiang пре 3 дана
родитељ
комит
b03d5c68ea
61 измењених фајлова са 1551 додато и 260 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 8 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 14 8
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 241 0
      apps/web/tests/client-plugin-live.e2e.ts
  5. 4 0
      apps/web/tests/expected/client-plugin-live/bootstrap-rebuild.expected.md
  6. 1 0
      apps/web/tests/expected/client-plugin-live/enabled.expected.md
  7. 1 0
      apps/web/tests/expected/client-plugin-live/recovered.expected.md
  8. 31 0
      apps/web/tests/fixtures/plugins/fixture-live-client/client.js
  9. 3 0
      apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml
  10. 2 0
      apps/web/tests/fixtures/plugins/fixture-live-client/index.js
  11. 13 0
      apps/web/tests/fixtures/plugins/fixture-live-client/package.json
  12. 1 0
      apps/web/tsconfig.json
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 1 1
      docs/config-catalog.md
  15. 1 1
      docs/config-catalog.zh.md
  16. 2 2
      docs/subsystems/client-modules.i18n.yaml
  17. 1 1
      docs/subsystems/client-modules.md
  18. 1 1
      docs/subsystems/client-modules.zh.md
  19. 2 2
      packages/client/hmr/README.i18n.yaml
  20. 10 10
      packages/client/hmr/README.md
  21. 10 10
      packages/client/hmr/README.zh.md
  22. 1 1
      packages/client/hmr/package.json
  23. 13 157
      packages/client/hmr/src/client/index.ts
  24. 5 5
      packages/client/hmr/src/events.ts
  25. 14 8
      packages/client/hmr/src/index.ts
  26. 88 0
      packages/client/hmr/tests/node-half.client.spec.ts
  27. 42 0
      packages/client/hmr/tests/transport.client.spec.ts
  28. 3 1
      packages/client/hmr/tsconfig.json
  29. 2 2
      packages/client/modules/README.i18n.yaml
  30. 11 1
      packages/client/modules/README.md
  31. 11 1
      packages/client/modules/README.zh.md
  32. 2 1
      packages/client/modules/package.json
  33. 247 0
      packages/client/modules/src/client/entries.ts
  34. 28 0
      packages/client/modules/src/client/entry-lifecycle.ts
  35. 2 0
      packages/client/modules/src/client/index.ts
  36. 5 2
      packages/client/modules/src/client/manifest.ts
  37. 71 12
      packages/client/modules/src/client/system.ts
  38. 516 0
      packages/client/modules/tests/entries.client.spec.ts
  39. 26 0
      packages/client/modules/tests/loader.client.spec.ts
  40. 6 5
      packages/client/modules/tsconfig.json
  41. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  42. 2 0
      packages/client/ui-settings-plugin-inventory/README.md
  43. 2 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  44. 5 2
      packages/client/ui-settings-plugin-inventory/package.json
  45. 18 1
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  46. 7 2
      packages/client/ui-settings-plugin-inventory/src/client/index.ts
  47. 6 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  48. 12 2
      packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
  49. 22 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  50. 6 0
      packages/client/ui-settings-plugin-inventory/tsconfig.json
  51. 2 2
      packages/client/web/README.i18n.yaml
  52. 2 0
      packages/client/web/README.md
  53. 2 0
      packages/client/web/README.zh.md
  54. 5 5
      packages/client/web/src/boot-client.ts
  55. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  56. 0 0
      packages/test-support/client-runtime/README.md
  57. 0 0
      packages/test-support/client-runtime/README.zh.md
  58. 2 2
      packages/test-support/client-runtime/src/assembly/test-client.ts
  59. 9 0
      pnpm-lock.yaml
  60. 1 0
      tsconfig.host.json
  61. 0 2
      vitest.config.ts

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

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

Разлика између датотеке није приказан због своје велике величине
+ 8 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


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

@@ -40,11 +40,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -68,25 +68,31 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
 
+### 动态图对账
+
+modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。Factory revision 的跟踪独立于条目激活,因为下载或物化完成后仍可能没有创建条目。图更新会在任何消费者导入依赖前,使未归属受管条目的陈旧 factory 和失败的到达目标失效,并清除其样式;重建帧也会对账因导入失败而缺失的条目。
+
+Host SSE 适配器转发现有图变化通知,并在连接时发送当前完整图。图描述浏览器的目标条目,不代表 Host 清理完成:Host 生命周期顺序属于其 Loader,每个浏览器则等待自身被移除 fiber 的清理。图对账与代码重建共用一个页面队列。本地代际阻止过期下载挂载;不透明 revision 只比较相等。失败页面报告本地错误,并可重试同一张图而不改变 Host 启用状态。这保留了无关页面状态,也无需重启应用或引入第二套插件执行器。Electron 的独立安装流程不属于此机制。
+
 ### 热重载:一个驱动插件,自行监视的 bundle
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
-浏览器侧,驱动插件每帧重载一个插件,串行执行
+浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器
 
 1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
 2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
 3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
 4. 排空旧 fiber 的各 disposer。
 5. 移除名下的 `<style data-plugin>` 标签。
-6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id
+6. 通过模块系统物化新导出,再调用 `entry.refresh()` 由 Loader 挂载。CSS 在旧 disposer 清理完成后重新注入;显式物化使导入错误能够被捕获,而不只留下 Loader 的控制台日志
 7. `fiber.await()`——让失败大声重抛。
 
-每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
+Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
 
-支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈
+重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠
 
 ## 包归属
 
@@ -96,7 +102,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件
 
 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。
 

+ 241 - 0
apps/web/tests/client-plugin-live.e2e.ts

@@ -0,0 +1,241 @@
+/** Real profile, Remote, bundle scripts and Cordis slots: page-local client lifecycle without navigation. */
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { chromium, type Page } from 'playwright'
+import { expect, it, onTestFailed, onTestFinished } from 'vitest'
+import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
+import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
+const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
+
+async function openInventory(page: Page, url: string) {
+  await page.goto(url, { waitUntil: 'load' })
+  await page.getByRole('button', { name: '设置', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '设置' })
+  await dialog.getByRole('button', { name: '插件', exact: true }).click()
+  await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
+  await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
+  return dialog
+}
+
+it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => {
+  const scaffold = await launchWebScaffold({
+    extraInstallAnchors: [join(FIXTURE, 'package.json')],
+  })
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const otherContext = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const page = await context.newPage()
+    const other = await otherContext.newPage()
+    const consoles = [watchConsole(page), watchConsole(other)]
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live'))
+    await openInventory(page, scaffold.authenticatedUrl)
+    const otherInventory = await openInventory(other, scaffold.authenticatedUrl)
+    let entryId: string | undefined
+    const toggle = async () => {
+      if (entryId === undefined) entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+      else { scaffold.ctx.loader.remove(entryId); entryId = undefined }
+    }
+    let navigations = 0
+    for (const target of [page, other]) target.on('framenavigated', () => { navigations++ })
+    const live = (target: Page) => target.locator('[data-live-client]')
+    const dataset = (target: Page) => target.evaluate(() => ({
+      liveMounts: document.documentElement.dataset.liveMounts,
+      liveDisposals: document.documentElement.dataset.liveDisposals,
+      liveHits: document.documentElement.dataset.liveHits,
+    }))
+    const ping = (target: Page) => target.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
+    expect(await live(page).count()).toBe(0)
+    expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(false)
+
+    await toggle()
+    for (const target of [page, other]) {
+      await live(target).waitFor()
+      expect(await live(target).evaluate(el => getComputedStyle(el).color)).toBe('rgb(12, 34, 56)')
+      await ping(target)
+      expect((await dataset(target)).liveHits).toBe('1')
+    }
+    await compareOrRefreshGolden(join(EXPECTED, 'enabled.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
+
+    // The inventory filter is page-owned state that live composition must preserve.
+    const draft = otherInventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    await toggle()
+    for (const target of [page, other]) {
+      await expect.poll(() => live(target).count()).toBe(0)
+      await expect.poll(async () => (await dataset(target)).liveDisposals).toBe('1')
+      await ping(target)
+      expect((await dataset(target)).liveHits).toBe('1')
+      expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
+    }
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    await toggle()
+    for (const target of [page, other]) {
+      await live(target).waitFor()
+      expect(await live(target).count()).toBe(1)
+      expect((await dataset(target)).liveMounts).toBe('2')
+      expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
+    }
+
+    const disconnected = other.waitForEvent('requestfailed', request => request.url().includes('/plugins/events'))
+    await otherContext.setOffline(true)
+    // Chromium offline emulation leaves established SSE sockets open. Cycling this
+    // fixture's transport plugin closes them without replacing the Host or profile.
+    const transport = [...scaffold.ctx.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-client-hmr')!.fiber!
+    const reconnected = page.waitForResponse(response => response.url().includes('/plugins/events') && response.status() === 200)
+    await transport.restart()
+    await disconnected
+    await reconnected
+    await toggle()
+    await expect.poll(() => live(page).count()).toBe(0)
+    expect(await live(other).count()).toBe(1)
+    await otherContext.setOffline(false)
+    await expect.poll(() => live(other).count(), { timeout: 20_000 }).toBe(0)
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(navigations).toBe(0)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    for (const console of consoles) expect(console.pageErrors).toEqual([])
+  } finally {
+    await browser.close()
+    await scaffold.close()
+  }
+}, 90_000)
+
+it('keeps a failed client download local and retries without changing Host enablement', async () => {
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live-retry'))
+    await openInventory(page, scaffold.authenticatedUrl)
+    const bundle = (url: URL) => url.pathname.startsWith('/plugins/') && url.search.includes('@fixture/live-client/client.js')
+    await page.route(bundle, route => route.abort())
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.waitFor()
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+    expect(await page.locator('[data-live-client]').count()).toBe(0)
+    expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(true)
+    await page.unroute(bundle)
+    await failure.getByRole('button', { name: '重试本页面同步' }).click()
+    await page.locator('[data-live-client]').waitFor()
+    await expect.poll(() => failure.count()).toBe(0)
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+  } finally {
+    await browser.close()
+    await scaffold.close()
+  }
+}, 90_000)
+
+it('recovers an uncreated client entry with rebuilt factory code without navigation', async () => {
+  const fixture = await mkdtemp(join(tmpdir(), 'dsh-client-rebuild-'))
+  // Finished hooks unwind in reverse order, so the Host closes before its fixture is removed.
+  onTestFinished(() => rm(fixture, { recursive: true, force: true }))
+  await cp(FIXTURE, fixture, { recursive: true })
+  const file = join(fixture, 'client.js')
+  const source = await readFile(file, 'utf8')
+  const broken = source.replace("const React = require('react')", "throw new Error('fixture r0 factory failed')")
+  expect(broken).not.toBe(source)
+  await writeFile(file, broken)
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(fixture, 'package.json')] })
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-factory-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/fixture r0 factory failed/).waitFor()
+    const rebuilt = source.replace('动态插件已启用', '动态插件 r1 已启用').replace('Live plugin enabled', 'Live plugin r1 enabled')
+    await writeFile(file, rebuilt)
+    scaffold.ctx.clientModules.rebuilt('@fixture/live-client')
+    await page.getByText('动态插件 r1 已启用', { exact: true }).waitFor()
+    await expect.poll(() => failure.count()).toBe(0)
+    await compareOrRefreshGolden(join(EXPECTED, 'recovered.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
+    expect(await page.evaluate(() => document.documentElement.dataset.liveMounts)).toBe('1')
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+  } finally {
+    await browser.close()
+  }
+})
+
+it('reports bootstrap rebuilds without remounting the settings page or navigating', async () => {
+  const scaffold = await launchWebScaffold()
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    const console = watchConsole(page)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-bootstrap-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    const originalInput = await draft.elementHandle()
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    scaffold.ctx.clientModules.rebuilt('@deepseek-ai/dsh-client-modules')
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await failure.getByRole('button', { name: '重试本页面同步' }).click()
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await compareOrRefreshGolden(join(EXPECTED, 'bootstrap-rebuild.expected.md'), await captureStableAria(page, '[data-client-sync-failure]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await originalInput!.evaluate(input => input.isConnected)).toBe(true)
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+    expect(console.pageErrors).toEqual([])
+  } finally {
+    await browser.close()
+  }
+})
+
+it('removes the client UI and resources while Host cleanup is still pending', async () => {
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
+  let release!: () => void
+  const cleanup = new Promise<void>((resolve) => { release = resolve })
+  onTestFinished(async () => { release(); await scaffold.close() })
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    await openInventory(page, scaffold.authenticatedUrl)
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const live = page.locator('[data-live-client]')
+    await live.waitFor()
+    const fiber = scaffold.ctx.loader.resolve(entryId).fiber!
+    let hostDisposed = false
+    fiber.ctx.effect(() => async () => { await cleanup; hostDisposed = true })
+    scaffold.ctx.loader.remove(entryId)
+    await expect.poll(() => live.count()).toBe(0)
+    await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveDisposals)).toBe('1')
+    expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
+    await page.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
+    expect(await page.evaluate(() => document.documentElement.dataset.liveHits)).toBeUndefined()
+    expect(hostDisposed).toBe(false)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+    release()
+    while (fiber.inertia !== undefined) await fiber.inertia
+    expect(hostDisposed).toBe(true)
+  } finally {
+    await browser.close()
+  }
+})

+ 4 - 0
apps/web/tests/expected/client-plugin-live/bootstrap-rebuild.expected.md

@@ -0,0 +1,4 @@
+- alert: 本页面的插件未能完成同步;服务端的启用状态保持不变。
+- list:
+  - listitem: "@deepseek-ai/dsh-client-modules: Error: client-modules: replacing bootstrap module @deepseek-ai/dsh-client-modules requires a page reload"
+- button "重试本页面同步"

+ 1 - 0
apps/web/tests/expected/client-plugin-live/enabled.expected.md

@@ -0,0 +1 @@
+- text: 动态插件已启用

+ 1 - 0
apps/web/tests/expected/client-plugin-live/recovered.expected.md

@@ -0,0 +1 @@
+- text: 动态插件 r1 已启用

+ 31 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/client.js

@@ -0,0 +1,31 @@
+/** Test package using the published registration protocol, locale and slots. */
+window.__ModuleLoader__.load({
+  id: '@fixture/live-client',
+  factory(require) {
+    const React = require('react')
+    const style = document.createElement('style')
+    style.dataset.plugin = '@fixture/live-client'
+    style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
+    document.head.append(style)
+    return {
+      inject: ['slots', 'locale'],
+      apply(ctx) {
+        const counters = document.documentElement.dataset
+        counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1)
+        ctx.effect(() => ctx.locale.register('fixtureLive', { zh: { active: '动态插件已启用' }, en: { active: 'Live plugin enabled' } }))
+        ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+          name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive',
+        }, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active'))))
+        ctx.effect(() => {
+          const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) }
+          window.addEventListener('dsh-fixture-ping', ping)
+          return async () => {
+            window.removeEventListener('dsh-fixture-ping', ping)
+            await Promise.resolve()
+            counters.liveDisposals = String(Number(counters.liveDisposals ?? 0) + 1)
+          }
+        })
+      },
+    }
+  },
+})

+ 3 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml

@@ -0,0 +1,3 @@
+- insert:
+    - id: fixture-live-client
+      name: '@fixture/live-client'

+ 2 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/index.js

@@ -0,0 +1,2 @@
+/** Host companion for the live browser lifecycle fixture. */
+export function apply() {}

+ 13 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/package.json

@@ -0,0 +1,13 @@
+{
+  "name": "@fixture/live-client",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "index.js",
+  "exports": { ".": "./index.js", "./client": "./client.js" },
+  "dsh": {
+    "title": "动态界面示例",
+    "bundle": { "patch": "./cordis.patch.yml" },
+    "client": { "platform": "web" }
+  }
+}

+ 1 - 0
apps/web/tsconfig.json

@@ -46,6 +46,7 @@
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
     "tests/plugin-config.e2e.ts",
+    "tests/client-plugin-live.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
     "tests/deepseek-messages-settings.e2e.ts",

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: dffb33fedc3650f972661f77c3339628517153ad
-config-catalog.zh.md: 35805a0428b4d9ad3410ed4c7b7da4bbc32a704d
+config-catalog.md: 53a552060d3e16d7bf8f242fe32a49e597cc2fc0
+config-catalog.zh.md: 9bf2c18a9ccdca5fcd95dfa6f239632969587c47

+ 1 - 1
docs/config-catalog.md

@@ -444,7 +444,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
+Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -446,7 +446,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
+来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 

+ 2 - 2
docs/subsystems/client-modules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
-client-modules.md: 74aa2b4983f3c8c7fc6b3ba9b03646ac8141f3f1
-client-modules.zh.md: 259717a44a3275ef5b94df698fc01b5de7431fd0
+client-modules.md: aff74a1da1ce524689d1a746a7267eb1544e4fef
+client-modules.zh.md: 8100708edd88d35f123d658de42525f04a90ebd6

+ 1 - 1
docs/subsystems/client-modules.md

@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
 
 `ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
-In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts revision changes to the browser half over SSE. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Production graphs omit the HMR row entirely; the module host itself never watches files.
+[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 1 - 1
docs/subsystems/client-modules.zh.md

@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
 
 `ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 revision 变化广播给浏览器半。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件
+随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
packages/client/hmr/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/hmr/README.md
-README.md: 26bc640c6cdd213fbc78de920e12444589901990
-README.zh.md: 72d941f678e7e29a56bca678a79c4740667e7e6b
+README.md: da7750e26827148d3bcd41395c50b98d6c8c6678
+README.zh.md: 9e1291dbd1e3c3ceb75504e4461cd41a3fe95ade

+ 10 - 10
packages/client/hmr/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Development-only hot reload for browser client plugins: rebuilding a plugin bundle swaps the running plugin in place, for developers iterating on the web GUI."
+description: "Live graph synchronization and development bundle reloads for Web client plugins."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-hmr` reloads a browser client plugin in place when its bundle is rebuilt, so a developer editing plugin source sees the change without a full page reload. The reload chain stays idle without a rebuild watcher: only a `pnpm run dev:web`-style process rewriting client bundles produces the rebuilds it reacts to. Each reload swaps one plugin with fresh component state while the data layer (connection, runtime, and Session objects) stays untouched. Everything here is development machinery in the browser; the model never sees it.
+`dsh-client-hmr` keeps open Web pages in sync with the Host plugin graph and reloads rebuilt browser bundles. Ordinary plugin enable/disable changes take effect without reloading the page or restarting the Host. Code rebuilds replace the affected plugin with fresh component state. The model sees no new input or output.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Enable the rebuild watcher for the plugin you are editing, then save: the browser picks up the rebuilt bundle from the dev server and swaps the plugin without reloading the page. Use it during client development; nothing observable happens in a production build, where no watcher rewrites bundles.
+The shipped Web composition mounts this transport for live plugin changes. During development, a bundle watcher also supplies code rebuilds. Disabling the transport stops graph delivery to open pages.
 
 ### Starting the reload chain
 
@@ -33,7 +33,7 @@ Run `pnpm run dev:web` (or any tsdown watch process that writes the plugin's `li
 
 ### What a reload does
 
-Each reload re-executes the plugin bundle and remounts the plugin with fresh state. Plugins that depend on the reloaded one reload with it automatically. A reload that fails is reported visibly and retried from scratch on the next rebuild.
+Each successful reload re-executes the plugin bundle and remounts the plugin with fresh state. Plugins that depend on the reloaded one reload with it automatically. Failures appear in the plugin list, where they can be retried without waiting for another rebuild.
 
 ### Configuration
 
@@ -59,11 +59,11 @@ This section explains how the reload chain is built; observable behavior is cove
 
 ### Design concept
 
-The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames.
+The Host half watches bundle artifacts and serves `/plugins/events`. It forwards existing graph-change and rebuilt notifications; every new connection receives the current full graph. A graph describes the browser’s desired entries and carries no Host cleanup-completion guarantee. Host activation and cleanup remain owned by the Host lifecycle. Artifact polling reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to Client Modules, which serializes entry changes and waits for browser resource cleanup.
 
 ### The browser swap
 
-On a `rebuilt` frame the revision makes `invalidate` select that plugin's immutable one-resource combo URL instead of its initial multi-resource URL. `prefetch` loads and registers the new factory while the old fiber still serves. The remaining order is registry-first teardown (`registry.delete` before the fiber's disposer emits `internal/plugin`, or the vendored Loader flags the entry disabled), drain the old fiber's unload, delete `entry.fiber`, remove owned `<style data-plugin>` tags, then `entry.refresh()` re-imports and remounts, and `fiber.await()` rethrows startup failures loudly. The swap is safe because execution is pure registration under the lazy-CJS model: every module side effect lives in the factory closure and runs at materialization.
+On a `rebuilt` frame, the controller invalidates the old module and prefetches its single-resource script while the old fiber still serves. It then deletes the registry runtime, drains the old fiber, clears its entry reference and removes owned styles. The module system materializes the new exports before `entry.refresh()` mounts them through Loader; this exposes import failures to page diagnostics even when Loader would only log them. CSS is injected after old effects have finished.
 
 ### Cascade and self-reload
 
@@ -71,14 +71,14 @@ A fiber's activation epoch strings its service providers' uids, so replacing a p
 
 ### Failure policy
 
-No rollback: an import failure leaves the entry fiberless (the next rebuilt frame retries from scratch), and an apply failure leaves a FAILED fiber visible in the shell's status projection. Both log loudly.
+Download failures leave the running plugin active. After the old fiber is torn down, import or activation failure does not restore the previous bundle. Failures appear as page-local synchronization errors. Settings → Plugins → Plugin list retries the latest graph, even when its revision is unchanged; a later rebuild also retries the affected plugin. Successful unrelated plugins remain active.
 
 ### Source map
 
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Node half: bundle stat-poll, `rebuilt` reporting, `/plugins/events` SSE channel |
-| [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription, serialized reload queue, fiber swap |
+| [`src/client/index.ts`](src/client/index.ts) | Browser half: SSE subscription and delegation to the shared entry controller |
 | [`src/events.ts`](src/events.ts) | Shared frame types (`graph` / `rebuilt`) and the endpoint constant |
 
 </details>
@@ -114,8 +114,8 @@ None; this package neither assembles nor sends a provider request.
 These limits define what the reload driver does not preserve or restore. They are current package constraints, not a task backlog.
 
 - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with re-executing the bundle and is deliberately out.
-- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
-- **Rebuilt frames do not replace the boot graph** — each frame carries the plugin-artifact revision needed for its one-resource combo reload; a page reload receives the recomposed startup graph.
+- **No failure rollback** — after the old fiber is torn down, a failed replacement does not restore the previous bundle.
+- **Web transport only** — Electron installation and backend restart handling do not use this SSE path. Entry reconciliation itself is transport-independent.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 10 - 10
packages/client/hmr/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "仅用于开发环境的浏览器客户端插件热重载:重建插件 bundle 后原地替换运行中的插件,供开发者迭代 web GUI。"
+description: "Web 客户端插件的动态图同步与开发时 bundle 重载。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-hmr` 会在浏览器客户端插件的 bundle 重建后原地重载该插件,让编辑插件源码的开发者无需整页刷新即可看到变更。如果没有重建 watcher,整条链路保持空闲:只有 `pnpm run dev:web` 之类的进程重写客户端 bundle 时才会产生它所响应的重建。每次重载只替换一个插件并携带全新组件状态,而数据层(连接、运行时与 Session 对象)保持不变。这里的一切都是浏览器侧的开发机制;模型永远看不到它
+`dsh-client-hmr` 让已打开的 Web 页面与 Host 插件图保持同步,并重载重建后的浏览器 bundle。普通插件的启停无需刷新页面或重启 Host 即可生效。代码重建会替换受影响插件并重置其组件状态。模型不会收到新的输入或输出
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-为正在编辑的插件启用重建 watcher,然后保存:浏览器会从 dev server 拾取重建后的 bundle,并在不重载页面的情况下替换该插件。在客户端开发期间使用它;在生产构建中没有任何可观察行为,因为没有 watcher 会重写 bundle
+随包提供的 Web 组合挂载此传输,以交付插件动态变更。开发时,bundle watcher 还会提供代码重建。停用此传输会停止向已打开的页面交付图更新
 
 ### 启动重载链路
 
@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 一次重载做什么
 
-每次重载都会重新执行插件 bundle,并用全新状态重新挂载插件。依赖被重载插件的插件会随之自动重载。失败的重载会以可见方式报告,并在下一次重建时从头重试
+每次成功的重载都会重新执行插件 bundle,并用全新状态重新挂载插件。依赖被重载插件的插件会随之自动重载。失败会显示在插件列表中,可直接重试,无需等待下一次重建
 
 ### 配置
 
@@ -59,11 +59,11 @@ kind: "package-reference"
 
 ### 设计理念
 
-链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE(Server-Sent Events)通道
+Host 半侧监听 bundle 产物并提供 `/plugins/events`。它转发现有的图变化与重建通知;每个新连接都会收到当前完整图。图描述浏览器的目标条目,不保证 Host 清理已经完成。Host 的激活与清理仍由 Host 生命周期管理。产物轮询报告重建 revision;未变化的产物无需读取内容。浏览器半侧将两种帧都交给 Client Modules,由它串行处理条目变更并等待浏览器资源清理
 
 ### 浏览器侧替换
 
-收到 `rebuilt` 帧后,帧内 revision 会让 `invalidate` 选择该插件不可变的单资源 combo URL,而不是初始多资源 URL。`prefetch` 在旧 fiber 仍在服务时加载并注册新 factory。其余顺序是:先从注册表删除,再拆卸(在 fiber 的 disposer 发出 `internal/plugin` 之前执行 `registry.delete`,否则 vendored Loader 会把该 entry 标为禁用)、等待旧 fiber 卸载完成、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签,然后 `entry.refresh()` 重新导入并挂载,`fiber.await()` 直接把启动失败重新抛出。替换之所以安全,是因为在惰性 CJS 模型下执行只是注册:每个模块副作用都位于 factory 闭包中,在物化时运行
+收到 `rebuilt` 帧后,控制器使旧模块失效,并在旧 fiber 仍然服务时预取其单资源脚本。随后删除注册表 runtime、等待旧 fiber 清理、清除条目中的 fiber 引用并移除自身样式。模块系统先物化新导出,再由 `entry.refresh()` 通过 Loader 挂载;这样即使 Loader 只记录导入错误,页面诊断仍能获得失败原因。CSS 在旧 effect 完成清理后注入
 
 ### 级联与自重载
 
@@ -71,14 +71,14 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
 
 ### 失败策略
 
-不回滚:导入失败会让 entry 失去 fiber(下一个 `rebuilt` 帧从头重试),apply 失败则会在外壳的状态投影中留下 FAILED fiber。两者都会输出醒目的错误日志
+下载失败时,正在运行的插件保持活动。旧 fiber 被卸载后,导入或激活失败不会恢复先前的 bundle。失败会显示为当前页面的同步错误。「设置 → 插件 → 插件列表」会针对最新图重试,即使其 revision 未变化;后续重建也会重试受影响的插件。无关且已成功运行的插件保持活动
 
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | node 半侧:bundle stat 轮询、`rebuilt` 上报、`/plugins/events` SSE 通道 |
-| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅、串行重载队列、fiber 替换 |
+| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:SSE 订阅与共享条目控制器调用 |
 | [`src/events.ts`](src/events.ts) | 共享帧类型(`graph` / `rebuilt`)与端点常量 |
 
 </details>
@@ -114,8 +114,8 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
 这些限制说明重载驱动器不会保留或恢复什么。它们是当前包约束,不是任务积压。
 
 - **重载有意保持粗粒度**——全新 fiber 与全新组件;被重载插件内的 React 状态会丢失,而数据层(连接 fiber、运行时 fiber、Session 对象)不受影响。react-refresh 级状态保留与重新执行 bundle 冲突,因此有意排除。
-- **失败时不回滚**——失败的重载会让该 entry 保持 FAILED 并在 loader 状态投影中可见;系统不会自动恢复先前 bundle。
-- **重建帧不会替换启动图**——每个帧都携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图
+- **失败时不回滚**——旧 fiber 被卸载后,替换失败不会恢复先前的 bundle。
+- **仅负责 Web 传输**——Electron 的安装和后端重启流程不使用此 SSE 路径。条目对账本身不依赖传输
 
 <a id="dev-note"></a>
 ### 开发备注

+ 1 - 1
packages/client/hmr/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
-  "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
+  "description": "Web client graph synchronization and rebuilt-bundle reload transport",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"

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

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

+ 5 - 5
packages/client/hmr/src/events.ts

@@ -1,5 +1,5 @@
 /**
- * Wire protocol of the `/plugins/events` dev SSE channel — single source for
+ * Wire protocol of the `/plugins/events` 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.
@@ -7,19 +7,19 @@
 
 import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
 
-/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
+/** One SSE frame: the settled graph on connection or change, or one rebuilt bundle notice. */
 export type PluginsEventFrame =
   | { type: 'graph'; graph: WebBootGraph }
   | { type: 'rebuilt'; id: string; rev: string }
 
 /** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */
 export type PluginsEventParseResult =
-  | { kind: 'frame'; frame: PluginsEventFrame }
+  | { kind: 'frame'; frame: { type: 'graph'; graph: unknown } | Extract<PluginsEventFrame, { type: 'rebuilt' }> }
   | { kind: 'unknown' }
   | { kind: 'invalid' }
 
 /**
- * Validate one JSON-decoded SSE payload before it can mutate module state.
+ * Validate the frame envelope; the module controller parses the complete graph before updating its index.
  * @param value - Parsed JSON value from the EventSource message.
  * @returns the known frame, an unknown-type marker, or an invalid marker.
  */
@@ -33,7 +33,7 @@ export function parsePluginsEventFrame(value: unknown): PluginsEventParseResult
         : { kind: 'invalid' }
     case 'graph':
       return typeof record.graph === 'object' && record.graph !== null
-        ? { kind: 'frame', frame: { type: 'graph', graph: record.graph as WebBootGraph } }
+        ? { kind: 'frame', frame: { type: 'graph', graph: record.graph } }
         : { kind: 'invalid' }
     default:
       return typeof record.type === 'string' ? { kind: 'unknown' } : { kind: 'invalid' }

+ 14 - 8
packages/client/hmr/src/index.ts

@@ -1,12 +1,11 @@
 /**
- * HMR plugin, node half: the host end of the dev reload chain. One interval
+ * Host transport for Web client graph changes and rebuilt bundles. One interval
  * stat-polls every graph row's client bundle (polling by design: network mounts
  * deliver no inotify events), reports changes through
  * `clientModules.rebuilt(id)`, and serves the `/plugins/events` SSE channel
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
- * The web bundle mounts this row unconditionally: without a rebuild
- * watcher rewriting client bundles, the poll observes no changes and the
- * chain stays idle.
+ * The Web composition mounts this transport for live graph updates;
+ * a development rebuild watcher also supplies bundle changes.
  */
 import { statSync } from 'node:fs'
 import type { ServerResponse } from 'node:http'
@@ -24,7 +23,7 @@ export { EVENTS_ENDPOINT } from './events.ts'
 /** Cordis plugin name. */
 export const name = 'client-hmr'
 
-/** Required services: the web plugin table and the route registry. */
+/** Required services: the client graph and Web route registry. */
 export const inject = ['clientModules', 'webServer']
 
 /** Plugin config, validated by the same-named schemastery schema. */
@@ -61,7 +60,7 @@ function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): bool
 }
 
 /**
- * Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
+ * Mount bundle watches and graph/rebuilt SSE delivery.
  * @param ctx - host plugin context carrying clientModules and webServer.
  * @param config - validated {@link Config}.
  */
@@ -143,7 +142,7 @@ export function apply(ctx: Context, config: Config): void {
   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).
+    // own row; bootstrap revisions also reach page diagnostics).
     syncWatches()
     const unsubscribe = ctx.clientModules.onGraphChanged(syncWatches)
     const timer = setInterval(pollWatches, pollIntervalMs)
@@ -158,6 +157,11 @@ export function apply(ctx: Context, config: Config): void {
   // --- /plugins/events SSE channel ----------------------------------------
   const connections = new Set<ServerResponse>()
 
+  const publishGraph = (): void => {
+    const line = sseData({ type: 'graph', graph: ctx.clientModules.graph() })
+    for (const res of connections) res.write(line)
+  }
+
   const connect = (res: ServerResponse): void => {
     res.writeHead(200, {
       'content-type': 'text/event-stream',
@@ -167,8 +171,8 @@ export function apply(ctx: Context, config: Config): void {
     // 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.clientModules.graph() }))
     connections.add(res)
+    res.write(sseData({ type: 'graph', graph: ctx.clientModules.graph() }))
     res.on('close', () => { connections.delete(res) })
   }
 
@@ -187,11 +191,13 @@ export function apply(ctx: Context, config: Config): void {
         connect(res)
       },
     })
+    const unsubscribeGraph = ctx.clientModules.onGraphChanged(publishGraph)
     const unsubscribe = ctx.clientModules.onRebuilt((id, rev) => {
       const line = sseData({ type: 'rebuilt', id, rev })
       for (const res of connections) res.write(line)
     })
     return () => {
+      unsubscribeGraph()
       unsubscribe()
       disposeRoute()
       for (const res of connections) res.destroy()

+ 88 - 0
packages/client/hmr/tests/node-half.client.spec.ts

@@ -2,9 +2,12 @@
  * Node half of the HMR plugin: bundle watches follow the graph, stat changes
  * report through clientModuleHost.rebuilt, and everything dies with the fiber.
  */
+import { EventEmitter } from 'node:events'
+import type { ServerResponse, IncomingMessage } from 'node:http'
 import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
@@ -226,3 +229,88 @@ describe('hmr node half', () => {
     await fiber.dispose()
   })
 })
+
+
+it('broadcasts the desired graph without waiting for Host activation or cleanup', async () => {
+  const ctx = new Context()
+  await ctx.plugin(Loader)
+  const bundle = join(dir, 'a.js')
+  writeFileSync(bundle, 'a')
+  const rows = new Map([['a', bundle]])
+  const host = fakeClientModuleHost(rows)
+  const routes: WebRoute[] = []
+  ctx.provide('clientModules', host)
+  ctx.provide('webServer', fakeHttpServer(routes))
+  let release!: () => void
+  let cleaned!: () => void
+  let started!: () => void
+  const starting = new Promise<void>((resolve) => { started = resolve })
+  const activation = new Promise<void>((resolve) => { release = resolve })
+  const cleanup = new Promise<void>((resolve) => { cleaned = resolve })
+  let disposed = false
+  ctx.loader.internal = { version: 'client', import: async () => ({
+    apply: async (pluginCtx: Context) => {
+      pluginCtx.effect(() => async () => { await cleanup; disposed = true })
+      started()
+      await activation
+    },
+  }) } as never
+  const entryId = await ctx.loader.create({ name: 'owned' })
+  await starting
+  const fiber = ctx.plugin({ inject, Config, apply }, { pollIntervalMs: POLL_MS })
+  await fiber.await()
+  const route = routes[0]!
+  const connect = async () => {
+    const lines: string[] = []
+    const response = Object.assign(new EventEmitter(), {
+      writeHead: vi.fn(), write: (line: string) => { lines.push(line) },
+      destroy: vi.fn(), end: vi.fn(),
+    })
+    await route.handler({ method: 'GET' } as IncomingMessage, response as unknown as ServerResponse)
+    return { lines, response }
+  }
+  try {
+    const first = await connect()
+    expect(first.lines).toHaveLength(2)
+    const frame = JSON.parse(first.lines[1]!.slice(6)) as { graph: WebBootGraph }
+    expect(frame.graph.entries.map(row => row.id)).toEqual(['a'])
+    const second = await connect()
+    expect(second.lines[1]).toBe(first.lines[1])
+    expect(first.lines).toHaveLength(2)
+    release()
+    const owned = ctx.loader.resolve(entryId).fiber!
+    await owned.await()
+    const child = owned.ctx.plugin({ apply() {} })
+    await child.await()
+    expect(child.entry).toBe(owned.entry)
+    await child.dispose()
+    await child.await()
+    await ctx.loader.await()
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(second.lines).toHaveLength(2)
+    first.response.emit('close')
+    ctx.loader.remove(entryId)
+    rows.clear()
+    host.fireGraphChanged()
+    expect(second.lines).toHaveLength(3)
+    expect(disposed).toBe(false)
+    expect((JSON.parse(second.lines[2]!.slice(6)) as { graph: WebBootGraph }).graph.entries).toEqual([])
+    const third = await connect()
+    expect(third.lines[1]).toBe(second.lines[2])
+    expect(second.lines).toHaveLength(3)
+    cleaned()
+    while (owned.inertia !== undefined) await owned.inertia
+    expect(disposed).toBe(true)
+    expect(second.lines).toHaveLength(3)
+    await fiber.dispose()
+    host.fireGraphChanged()
+    expect(second.lines).toHaveLength(3)
+    expect(second.response.destroy).toHaveBeenCalledOnce()
+    expect(third.response.destroy).toHaveBeenCalledOnce()
+  } finally {
+    release()
+    cleaned()
+    await fiber.dispose()
+    await ctx.fiber.dispose()
+  }
+})

+ 42 - 0
packages/client/hmr/tests/transport.client.spec.ts

@@ -0,0 +1,42 @@
+/** Web transport delegates module and entry changes to the page-owned controller. */
+import { Context } from '@deepseek-ai/cordis'
+import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
+import { afterEach, expect, it, vi } from 'vitest'
+import { apply, inject } from '../src/client/index.ts'
+
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
+
+it('forwards full graphs and rebuilt frames, contains wire errors and closes its EventSource', async () => {
+  const ctx = new Context()
+  const sync = vi.fn(async () => {})
+  const reload = vi.fn(async () => {})
+  ctx.provide('modules', { entries: { sync, reload } } as unknown as ClientModuleLoader)
+  const warnings = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+  const errors = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+  let receive!: (event: { data: string }) => void
+  const close = vi.fn()
+  vi.stubGlobal('EventSource', class {
+    close = close
+    addEventListener(_name: string, listener: typeof receive) { receive = listener }
+  })
+  const fiber = ctx.plugin({ apply, inject })
+  try {
+    await fiber.await()
+    const graph = { rev: 'r', entries: [], batches: [] }
+    receive({ data: JSON.stringify({ type: 'graph', graph }) })
+    receive({ data: JSON.stringify({ type: 'rebuilt', id: 'a', rev: 'r1' }) })
+    await vi.waitFor(() => { expect(sync).toHaveBeenCalledWith(graph) })
+    expect(reload).toHaveBeenCalledWith('a', 'r1')
+    receive({ data: '{' })
+    receive({ data: JSON.stringify({ type: 'graph', graph: null }) })
+    receive({ data: JSON.stringify({ type: 'future' }) })
+    expect(warnings).toHaveBeenCalledTimes(2)
+    sync.mockRejectedValueOnce(new Error('invalid graph'))
+    receive({ data: JSON.stringify({ type: 'graph', graph: {} }) })
+    await vi.waitFor(() => { expect(errors).toHaveBeenCalledWith(expect.objectContaining({ message: 'invalid graph' })) })
+  } finally {
+    await fiber.dispose()
+    await ctx.fiber.dispose()
+  }
+  expect(close).toHaveBeenCalledOnce()
+})

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

@@ -8,7 +8,9 @@
       "DOM",
       "DOM.Iterable"
     ],
-    "types": ["node"]
+    "types": [
+      "node"
+    ]
   },
   "include": [
     "src"

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: ef303761b164c2d75d1757d3bce1810b4795c12b
-README.zh.md: 6c4c80e90ed2209901894b4d43ac28ad3b57e61e
+README.md: 1ea4976743eb0b396b02a765e51dec2a17f3c31b
+README.zh.md: 648af567b7d12f036ae50d5468cc88725bc052d5

+ 11 - 1
packages/client/modules/README.md

@@ -37,6 +37,10 @@ A browser plugin package declares `dsh.client` in its `package.json` with `platf
 
 The application combo scripts register plugin factories once during boot; module bodies remain lazy and run only at first import or materialization. Rows that share a combo URL share one in-flight script task. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
 
+### Live plugin composition
+
+An open Web page follows the Host's complete module graph through the HMR transport. Enabling an ordinary plugin adds its Loader entry; disabling it removes the entry and waits for its asynchronous effects before evicting unused modules and styles. Re-enabling loads one instance with its styles. Other Loader contributors and shared modules still needed by active entries remain loaded. Settings → Plugins → Plugin list shows page-local synchronization failures and offers retry without changing Host enablement.
+
 ### Sharing modules
 
 The shell seeds a frozen module table (`PLATFORM_MODULES`: React, Cordis, and static UI libraries); every dynamic bundle resolves its externals against exactly that baseline. `dsh.client.external` adds only exact non-baseline requests, each answered by the dynamic package row it names or an exact static-table key. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles.
@@ -75,6 +79,10 @@ The bundle route follows the injected `webServer` lifetime: it registers when th
 
 The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. The shell installs that returned system as its Loader's `internal`; the modules plugin publishes that instance as `ctx.modules`, so separate Cordis trees never select an instance through module-global state.
 
+### Entry ownership
+
+`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
+
 ### Source map
 
 | File | Role |
@@ -82,6 +90,8 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
 | [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
 | [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
 | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
+| [`src/client/entries.ts`](src/client/entries.ts) | Page entry reconciliation, retries and code replacement |
+| [`src/client/entry-lifecycle.ts`](src/client/entry-lifecycle.ts) | Loader fiber teardown through the registry and owned-style cleanup |
 | [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types, boot-manifest parsing, and the `dsh.client` declaration parser |
 
 </details>
@@ -118,7 +128,7 @@ None; this package neither assembles nor sends a provider request.
 These limits define what the module system does not do. They are current package constraints, not a task backlog.
 
 - **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
-- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
+- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
 - **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
 - **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
 

+ 11 - 1
packages/client/modules/README.zh.md

@@ -37,6 +37,10 @@ kind: "package-reference"
 
 application combo 脚本在启动时仅注册一次插件 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。共享 combo URL 的 row 共用一个进行中的脚本任务。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
 
+### 插件动态组合
+
+已打开的 Web 页面通过 HMR 传输跟随 Host 的完整模块图。启用普通插件会添加其 Loader 条目;停用会移除条目,并在其异步 effect 完成清理后回收未使用的模块与样式。再次启用会加载一个带样式的实例。其他 Loader 贡献方的条目及活动条目仍需使用的共享模块会保留。「设置 → 插件 → 插件列表」显示当前页面的同步失败,并提供不改变 Host 启用状态的重试。
+
 ### 共享模块
 
 外壳初始化一张冻结的模块表(`PLATFORM_MODULES`:React、Cordis 与静态 UI 库);每个动态 bundle 都精确针对该基座解析其 external。`dsh.client.external` 只添加基座之外的精确请求;系统会将每个请求解析到其指定的动态包 row 或完全匹配的静态表键。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求与同步请求环。
@@ -75,6 +79,10 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 
 宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。外壳把返回的系统装成自身 Loader 的 `internal`;modules 插件将该实例发布为 `ctx.modules`,因此不同 Cordis 树不会通过模块级全局状态选择实例。
 
+### 条目所有权
+
+`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
+
 ### 源码索引
 
 | 文件 | 职责 |
@@ -82,6 +90,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 | [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 |
 | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 |
 | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 |
+| [`src/client/entries.ts`](src/client/entries.ts) | 页面条目对账、重试与代码替换 |
+| [`src/client/entry-lifecycle.ts`](src/client/entry-lifecycle.ts) | 通过注册表清理 Loader fiber,回收模块自身样式 |
 | [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型、启动清单解析与 `dsh.client` 声明解析器 |
 
 </details>
@@ -118,7 +128,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
 
 - **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
-- **自身不维护卸载记录**——样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id
+- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步
 - **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
 - **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
 

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

@@ -46,7 +46,8 @@
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-package-manifest": "workspace:^"
+    "@deepseek-ai/dsh-package-manifest": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 247 - 0
packages/client/modules/src/client/entries.ts

@@ -0,0 +1,247 @@
+/** Page-owned Loader entries; transport-independent reconciliation, retries and code replacement. */
+import type { FiberState } from '@deepseek-ai/cordis'
+import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import { parseBootManifest } from './manifest.ts'
+import type { BootManifest, ClientModuleLoader } from './manifest.ts'
+import { removeOwnedStyles, tearDownEntryFiber } from './entry-lifecycle.ts'
+
+/** Page-local failures do not change the Host's bundle enablement. */
+export interface ClientEntryState {
+  /** True while a snapshot, retry or code replacement is being applied. */
+  readonly syncing: boolean
+  /** Package ids and errors from the latest reconciliation. */
+  readonly failures: readonly { readonly id: string; readonly message: string }[]
+}
+
+/** Module-table capabilities used within serialized entry operations. */
+interface ModuleIndex {
+  update(manifest: BootManifest, managed: Iterable<string>): void
+  invalidateForReplacement(id: string, rev: string): void
+  prune(roots: Iterable<string>): void
+}
+
+/** Numeric values mirror Cordis's const enum, which bundle loaders cannot import as a runtime object. */
+const ACTIVE = 2 as FiberState.ACTIVE
+const FAILED = 3 as FiberState.FAILED
+
+/** Revisions and requests identify desired code; URLs only select its immutable delivery resource. */
+function entryTargets(manifest: BootManifest): string {
+  return JSON.stringify(manifest.modules.map(row => [row.id, row.rev, row.inject, row.external]))
+}
+
+/** Manages only entries created from the Host manifest; other Loader contributors retain ownership. */
+export class ClientEntries {
+  /** Stable observable consumed by page diagnostics through the renderer's injected hook. */
+  readonly state: ObservableSnapshot<ClientEntryState> = {
+    getSnapshot: () => this.snapshot,
+    subscribe: (listener) => {
+      this.listeners.add(listener)
+      return () => { this.listeners.delete(listener) }
+    },
+  }
+  // The modules bootstrap factory cannot request platform libraries before the shell supplies its seed.
+  private snapshot: ClientEntryState = { syncing: false, failures: [] }
+  private readonly listeners = new Set<() => void>()
+  private readonly managed = new Map<string, Entry>()
+  private readonly revisions = new Map<string, string>()
+  private loader: Loader | undefined
+  private queue: Promise<void> = Promise.resolve()
+  private desired: BootManifest
+  private generation = 0
+  private stopped = false
+
+  /**
+   * Construct the page controller before Cordis boot.
+   * @param modules - Module arrival and materialization owner.
+   * @param index - Private descriptor replacement and unused-module cleanup.
+   */
+  constructor(private readonly modules: ClientModuleLoader, private readonly index: ModuleIndex) {
+    this.desired = modules.manifest
+  }
+
+  /**
+   * Create the initial roster and retain its entry identities for subsequent reconciliation.
+   * @param loader - Page Loader, already configured with the module system.
+   * @param manifest - Initial roster audited by the boot caller.
+   * @returns after initial entries and their activation settle; boot owns its activation audit.
+   */
+  start(loader: Loader, manifest: BootManifest): Promise<void> {
+    if (this.loader !== undefined) throw new Error('client-modules: entries already started')
+    this.loader = loader
+    this.desired = manifest
+    loader.ctx.effect(() => () => {
+      this.stopped = true
+      this.generation++
+      return this.queue
+    }, 'client-modules: entry reconciliation')
+    return this.enqueue(async () => {
+      await Promise.all(this.desired.plugins.map(async ({ id }) => {
+        await this.create(loader, id)
+      }))
+      await loader.await()
+      for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev)
+    })
+  }
+
+  /**
+   * Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads.
+   * @param graph - JSON-decoded graph received from the Host.
+   * @returns after the queued reconciliation; per-package failures remain available in {@link state}.
+   */
+  sync(graph: unknown): Promise<void> {
+    const manifest = parseBootManifest(graph)
+    if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
+    this.desired = manifest
+    const generation = this.generation
+    return this.enqueue(() => this.reconcile(generation))
+  }
+
+  /**
+   * Retry failed entries against the latest graph, including an unchanged revision.
+   * @returns after retry settlement, with remaining errors in {@link state}.
+   */
+  retry(): Promise<void> {
+    const generation = ++this.generation
+    return this.enqueue(() => this.reconcile(generation))
+  }
+
+  /**
+   * Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored.
+   * Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown.
+   * @param id - Package id from a rebuilt frame.
+   * @param rev - Opaque revision selecting the rebuilt artifact.
+   * @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}.
+   */
+  reload(id: string, rev: string): Promise<void> {
+    this.desired = {
+      ...this.desired,
+      modules: this.desired.modules.map(row => row.id === id ? { ...row, rev } : row),
+    }
+    return this.enqueue(async () => {
+      const desired = this.desired.modules.find(row => row.id === id)
+      if (this.stopped || desired === undefined) return
+      const entry = this.managed.get(id)
+      if (entry === undefined) {
+        this.modules.invalidate(id, desired.rev)
+        removeOwnedStyles(id)
+        await this.reconcile(this.generation)
+        return
+      }
+      if (this.revisions.get(id) === rev) return
+      this.publish({ syncing: true, failures: this.snapshot.failures.filter(failure => failure.id !== id) })
+      await this.replace(entry, id, rev, this.generation)
+      this.publish({ syncing: false, failures: this.snapshot.failures })
+    }, id)
+  }
+
+  private publish(snapshot: ClientEntryState): void {
+    this.snapshot = snapshot
+    for (const listener of [...this.listeners]) {
+      try {
+        listener()
+      } catch (error) {
+        // The page controller has no owning plugin Context for a scoped logger.
+        console.error('client-modules: synchronization subscriber failed', error)
+      }
+    }
+  }
+
+  private enqueue(task: () => Promise<void>, subject = 'graph'): Promise<void> {
+    const run = this.queue.then(task)
+    // Each operation reports its own failure; later operations must still run.
+    this.queue = run.then(() => undefined, (error: unknown) => {
+      this.publish({ syncing: false, failures: [
+        ...this.snapshot.failures.filter(failure => failure.id !== subject),
+        { id: subject, message: String(error) },
+      ] })
+    })
+    return run
+  }
+
+  private current(generation: number): boolean {
+    return !this.stopped && generation === this.generation
+  }
+
+  /** Keep ownership even when Loader rejects a module's plugin exports after inserting its entry. */
+  private async create(loader: Loader, id: string): Promise<void> {
+    const options = { name: id }
+    const entryId = loader.ensureId(options)
+    try {
+      await loader.create(options)
+    } finally {
+      this.managed.set(id, loader.resolve(entryId))
+    }
+  }
+
+  private async replace(entry: Entry, id: string, rev: string, generation: number): Promise<void> {
+    this.index.invalidateForReplacement(id, rev)
+    await this.modules.prefetch(id)
+    if (!this.current(generation)) return
+    await tearDownEntryFiber(entry)
+    removeOwnedStyles(id)
+    if (!this.current(generation)) return
+    await this.modules.import(id, '', {})
+    if (!this.current(generation)) return
+    await entry.refresh()
+    await entry.fiber?.await()
+    if (entry.fiber === undefined) throw new Error(`client-modules: ${id} import failed (see console)`)
+    this.revisions.set(id, rev)
+  }
+
+  private async reconcile(generation: number): Promise<void> {
+    if (!this.current(generation)) return
+    const loader = this.loader
+    if (loader === undefined) throw new Error('client-modules: entries have not started')
+    const manifest = this.desired
+    this.publish({ syncing: true, failures: [] })
+    const failures: { id: string; message: string }[] = []
+    this.index.update(manifest, this.managed.keys())
+    const wanted = new Set(manifest.plugins.map(row => row.id))
+    for (const [id, entry] of this.managed) {
+      if (wanted.has(id)) continue
+      const fiber = entry.fiber
+      loader.remove(entry.id)
+      this.managed.delete(id)
+      this.revisions.delete(id)
+      // Removed fibers no longer appear in Loader.getTasks().
+      while (fiber?.inertia !== undefined) await fiber.inertia
+    }
+    for (const row of manifest.modules) {
+      if (!this.current(generation)) break
+      try {
+        const entry = this.managed.get(row.id)
+        if (entry === undefined) {
+          await this.modules.prefetch(row.id)
+          if (!this.current(generation)) break
+          await this.modules.import(row.id, '', {})
+          if (!this.current(generation)) break
+          await this.create(loader, row.id)
+          this.revisions.set(row.id, row.rev)
+        } else if (this.revisions.get(row.id) !== row.rev) {
+          await this.replace(entry, row.id, row.rev, generation)
+        } else if (entry.fiber === undefined) {
+          await this.replace(entry, row.id, row.rev, generation)
+        } else if (entry.fiber.state === FAILED) {
+          entry.fiber.update(entry.options.config)
+        }
+      } catch (error) {
+        failures.push({ id: row.id, message: String(error) })
+      }
+    }
+    await loader.await()
+    for (const [id, entry] of this.managed) {
+      if (failures.some(failure => failure.id === id)) continue
+      if (entry.fiber?.state === ACTIVE) continue
+      try {
+        if (entry.fiber === undefined) throw new Error(`client-modules: ${id} import failed (see console)`)
+        await entry.fiber.await()
+        failures.push({ id, message: `client-modules: ${id} is waiting for activation` })
+      } catch (error) {
+        failures.push({ id, message: String(error) })
+      }
+    }
+    this.index.prune([...loader.entries()].map(entry => entry.options.name))
+    if (this.current(generation)) this.publish({ syncing: false, failures })
+  }
+}

+ 28 - 0
packages/client/modules/src/client/entry-lifecycle.ts

@@ -0,0 +1,28 @@
+/** Loader lifecycle operations shared by live graph reconciliation and code replacement. */
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+
+/**
+ * Release a runtime before clearing its entry fiber so Loader refresh can import new code.
+ * Registry deletion prevents Loader from treating replacement as a user disable.
+ * @param entry - Entry retained for code replacement.
+ */
+export async function tearDownEntryFiber(entry: Entry): Promise<void> {
+  const fiber = entry.fiber
+  if (fiber === undefined) return
+  const runtime = fiber.runtime
+  /* v8 ignore next -- Loader entries own plugin fibers; only the root context has a null runtime. */
+  if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
+  while (fiber.inertia !== undefined) await fiber.inertia
+  delete entry.fiber
+}
+
+/**
+ * Remove styles after their plugin's effect cleanup has settled.
+ * @param id - Package whose factory owns the style tags.
+ */
+export function removeOwnedStyles(id: string): void {
+  if (typeof document === 'undefined') return
+  for (const el of document.querySelectorAll('style[data-plugin]')) {
+    if (el.getAttribute('data-plugin') === id) el.remove()
+  }
+}

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

@@ -19,6 +19,8 @@ import type {
 } from './manifest.ts'
 
 export { ClientModuleSystem }
+export { tearDownEntryFiber } from './entry-lifecycle.ts'
+export type { ClientEntries, ClientEntryState } from './entries.ts'
 export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
 export type {
   BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,

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

@@ -31,6 +31,7 @@
 
 import type {} from '@deepseek-ai/cordis'
 import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
+import type { ClientEntries } from './entries.ts'
 import type { ClientModuleSystem } from './system.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -371,8 +372,10 @@ export interface ClientModuleRecord {
 export interface ClientModuleLoader {
   /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
   version: 'client'
-  /** Parsed Host boot graph shared with the web entry after module-system creation. */
+  /** Latest parsed Host graph, updated by live entry reconciliation. */
   manifest: BootManifest
+  /** Page-owned entry reconciliation, shared by boot, graph updates and HMR. */
+  entries: ClientEntries
   /** Materialized-module registry: id → record. The governance-side read API for entry exports. */
   loadCache: Map<string, ClientModuleRecord>
   /**
@@ -410,7 +413,7 @@ export interface ClientModuleLoader {
 
 /** Internal construction inputs assembled by the modules bundle's bootstrap export. */
 export interface ClientModuleSystemOptions {
-  /** Parsed boot graph owned by the resulting module system. */
+  /** Boot graph validated by {@link parseBootManifest}, owned by the resulting module system. */
   manifest: BootManifest
   /** Module-table seed: platform-singleton specifier → shell instance. */
   staticModules: Record<string, unknown>

+ 71 - 12
packages/client/modules/src/client/system.ts

@@ -5,6 +5,8 @@
  * state tables and the load/materialize machinery.
  */
 import { stripClientSuffix } from './manifest.ts'
+import { ClientEntries } from './entries.ts'
+import { removeOwnedStyles } from './entry-lifecycle.ts'
 import type {
   BootManifest, BootModuleRow, ClientBundleRegistration, ClientModuleLoader, ClientModuleRecord,
   ClientModuleSystemOptions,
@@ -60,16 +62,17 @@ const claimStyles = (id: string): string[] => {
  */
 export class ClientModuleSystem implements ClientModuleLoader {
   readonly version = 'client'
-  readonly manifest: BootManifest
+  manifest: BootManifest
+  readonly entries: ClientEntries
   readonly loadCache = new Map<string, ClientModuleRecord>()
 
   private readonly seed: Map<string, unknown>
-  private readonly factories = new Map<string, ClientBundleRegistration['factory']>()
+  private readonly factories = new Map<string, { factory: ClientBundleRegistration['factory']; rev: string | undefined }>()
   private readonly bootstrapIds = new Set<string>()
   /** In-flight script transport per URL; every row in one batch shares it. */
   private readonly pendingArrival = new Map<string, Promise<void>>()
   /** Single-resource combo URL selected by HMR after invalidating one row. */
-  private readonly reloadUrls = new Map<string, string>()
+  private readonly reloadTargets = new Map<string, { url: string; rev: string }>()
   /** 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, BootModuleRow>()
@@ -81,11 +84,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
    */
   constructor(options: ClientModuleSystemOptions) {
     this.manifest = options.manifest
+    this.entries = new ClientEntries(this, {
+      update: (manifest, managed) => { this.updateManifest(manifest, managed) },
+      invalidateForReplacement: (id, rev) => {
+        if (this.bootstrapIds.has(id)) throw new Error(`client-modules: replacing bootstrap module ${id} requires a page reload`)
+        this.invalidate(id, rev)
+      },
+      prune: (roots) => { this.prune(roots) },
+    })
     this.seed = new Map(Object.entries(options.staticModules))
     this.loadBundle = options.loadBundle ?? defaultLoadBundle
 
     for (const row of options.manifest.modules) {
-      if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
       this.graphRows.set(row.id, row)
     }
 
@@ -116,15 +126,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
     if (this.bootstrapIds.has(id) || this.factories.has(id)) {
       throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
     }
-    this.factories.set(id, registration.factory)
+    this.factories.set(id, {
+      factory: registration.factory,
+      rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
+    })
   }
 
   /** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
   private arrive(row: BootModuleRow): Promise<void> {
     const { id } = row
     if (this.loadCache.has(id) || this.factories.has(id)) return Promise.resolve()
-    const reloadUrl = this.reloadUrls.get(id)
-    const url = reloadUrl ?? row.initialUrl
+    const reload = this.reloadTargets.get(id)
+    const url = reload?.url ?? row.initialUrl
     let transport = this.pendingArrival.get(url)
     if (transport === undefined) {
       transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
@@ -134,8 +147,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
       if (!this.factories.has(id)) {
         throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
       }
-      if (reloadUrl !== undefined && this.reloadUrls.get(id) === reloadUrl) {
-        this.reloadUrls.delete(id)
+      if (reload !== undefined && this.reloadTargets.get(id) === reload) {
+        this.reloadTargets.delete(id)
       }
     })
   }
@@ -182,10 +195,13 @@ export class ClientModuleSystem implements ClientModuleLoader {
     this.materializing.add(id)
     try {
       const edges = new Set<string>()
-      const exports = registered(this.makeRequire(edges))
+      const exports = registered.factory(this.makeRequire(edges))
       const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
       this.loadCache.set(id, record)
       return record
+    } catch (error) {
+      removeOwnedStyles(id)
+      throw error
     } finally {
       this.materializing.delete(id)
     }
@@ -237,12 +253,55 @@ export class ClientModuleSystem implements ClientModuleLoader {
     await this.arriveGraphRow(row)
   }
 
+  /** Refresh descriptors and unowned factory revisions before any entry imports its dependencies. */
+  private updateManifest(manifest: BootManifest, managed: Iterable<string>): void {
+    for (const id of this.bootstrapIds) {
+      if (this.manifest.modules.some(row => row.id === id) && !manifest.modules.some(row => row.id === id)) {
+        throw new Error(`client-modules: removing bootstrap module ${id} requires a page reload`)
+      }
+    }
+    const owned = new Set(managed)
+    for (const row of manifest.modules) {
+      this.graphRows.set(row.id, { ...row, initialUrl: row.url })
+      const cachedRevision = this.factories.get(row.id)?.rev ?? this.reloadTargets.get(row.id)?.rev
+      if (!owned.has(row.id) && cachedRevision !== undefined && cachedRevision !== row.rev) {
+        this.invalidate(row.id, row.rev)
+        removeOwnedStyles(row.id)
+      }
+    }
+    this.manifest = manifest
+  }
+
+  /** Retain live Loader modules and their transitive requests before evicting unreferenced graph records. */
+  private prune(roots: Iterable<string>): void {
+    const retained = new Set<string>(this.bootstrapIds)
+    const visit = (specifier: string): void => {
+      const id = stripClientSuffix(specifier)
+      if (retained.has(id)) return
+      retained.add(id)
+      const row = this.graphRows.get(id)
+      for (const request of [...row?.external ?? [], ...row?.inject ?? [], ...this.loadCache.get(id)?.edges ?? []]) {
+        visit(request)
+      }
+    }
+    for (const row of this.manifest.modules) visit(row.id)
+    for (const id of roots) visit(id)
+    for (const id of this.graphRows.keys()) {
+      if (retained.has(id)) continue
+      this.graphRows.delete(id)
+      this.invalidate(id)
+      removeOwnedStyles(id)
+    }
+  }
+
   invalidate(id: string, rev?: string): void {
     const normalized = stripClientSuffix(id)
     if (this.bootstrapIds.has(normalized)) return
     const row = this.graphRows.get(normalized)
-    if (row !== undefined) this.reloadUrls.set(normalized, atRevision(row.url, rev ?? row.rev))
-    else this.reloadUrls.delete(normalized)
+    if (row !== undefined) {
+      const revision = rev ?? row.rev
+      this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
+    } else this.reloadTargets.delete(normalized)
     this.factories.delete(normalized)
     this.loadCache.delete(normalized)
   }

+ 516 - 0
packages/client/modules/tests/entries.client.spec.ts

@@ -0,0 +1,516 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply as provideModules, createClientModuleSystem } from '../src/client/index.ts'
+import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
+
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) {
+    await ctx.fiber.dispose()
+    await ctx.fiber.await()
+  }
+  document.head.querySelectorAll('style').forEach((el) => { el.remove() })
+  document.body.replaceChildren()
+  vi.restoreAllMocks()
+})
+
+const row = (id: string, rev = 'r0', extra: Partial<WebBootEntry> = {}): WebBootEntry => ({
+  id, rev, url: `/plugins/??${id}/client.js&rev=${rev}`, ...extra,
+})
+const graph = (...entries: WebBootEntry[]): WebBootGraph => ({
+  rev: JSON.stringify(entries), entries,
+  batches: entries.length === 0 ? [] : [{ phase: 'application', url: '/batch', rev: 'batch', entries: entries.map(row => row.id) }],
+})
+const deferred = () => {
+  let resolve!: () => void
+  const promise = new Promise<void>((done) => { resolve = done })
+  return { promise, resolve }
+}
+
+async function bench(initial: WebBootGraph, factories: Record<string, ClientBundleRegistration['factory']>, start = true) {
+  const ctx = new Context()
+  contexts.push(ctx)
+  const fetched: string[] = []
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue', pendingQueue: [], load: () => {},
+    create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { inject: ['loader'], apply: provideModules } }, options),
+  }
+  let arrival: (url: string) => Promise<void> = async () => {}
+  const modules = target.create({
+    boot: initial, staticModules: {},
+    loadBundle: async (url) => {
+      fetched.push(url)
+      const ids = url === '/batch' ? initial.entries.map(row => row.id).filter(id => id !== 'bootstrap') : [url.split('??')[1]!.split('/client.js')[0]!]
+      const registrations = ids.map(id => ({ id, factory: factories[id]! }))
+      await arrival(url)
+      for (const registration of registrations) target.load(registration)
+    },
+  })
+  await ctx.plugin(Loader)
+  ctx.loader.internal = modules as never
+  if (start) await modules.entries.start(ctx.loader, modules.manifest)
+  return { ctx, modules, fetched, target, arrival: (fn: typeof arrival) => { arrival = fn } }
+}
+
+/** A visible plugin whose style and listener are owned by its factory and fiber respectively. */
+function visible(id: string, effects: { mounted: number; disposed: number; hits: number }, cleanup?: () => Promise<void>) {
+  return () => {
+    const style = document.createElement('style')
+    style.dataset.plugin = id
+    style.textContent = `[data-live="${id}"] { color: rgb(12, 34, 56); }`
+    document.head.append(style)
+    return { apply(ctx: Context) {
+      ctx.effect(() => {
+        effects.mounted++
+        const el = document.createElement('div')
+        el.dataset.live = id
+        document.body.append(el)
+        const listener = () => { effects.hits++ }
+        window.addEventListener('live-test', listener)
+        return async () => {
+          window.removeEventListener('live-test', listener)
+          el.remove()
+          await cleanup?.()
+          effects.disposed++
+        }
+      })
+    } }
+  }
+}
+
+describe('client manifest entries', () => {
+  it('adds, drains removal and re-enables one instance with styles; unrelated entries survive', async () => {
+    const effects = { mounted: 0, disposed: 0, hits: 0 }
+    const cleanup = deferred()
+    const b = await bench(graph(), { pet: visible('pet', effects, () => cleanup.promise) })
+    b.target.load({ id: 'local', factory: () => ({ apply() {} }) })
+    const localId = await b.ctx.loader.create({ name: 'local' })
+    await b.modules.entries.sync(graph(row('pet')))
+    expect(b.fetched).toEqual([row('pet').url])
+    expect(document.querySelectorAll('[data-live=pet]')).toHaveLength(1)
+    expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
+    window.dispatchEvent(new Event('live-test'))
+    expect(effects.hits).toBe(1)
+    const removing = b.modules.entries.sync(graph())
+    await vi.waitFor(() => { expect(document.querySelector('[data-live=pet]')).toBeNull() })
+    const readding = b.modules.entries.sync(graph(row('pet')))
+    expect(effects.disposed).toBe(0)
+    expect(effects.mounted).toBe(1)
+    cleanup.resolve()
+    await Promise.all([removing, readding])
+    expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 1 })
+    expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
+    expect(b.ctx.loader.resolve(localId).fiber?.state).toBe(2)
+    await b.modules.entries.sync(graph())
+    window.dispatchEvent(new Event('live-test'))
+    expect(effects).toEqual({ mounted: 2, disposed: 2, hits: 1 })
+    expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(0)
+    expect(b.modules.loadCache.has('pet')).toBe(false)
+    expect(b.modules.entries.state.getSnapshot()).toEqual({ syncing: false, failures: [] })
+  })
+
+  it('does not mount an obsolete download and can load it again later', async () => {
+    const effects = { mounted: 0, disposed: 0, hits: 0 }
+    const b = await bench(graph(), { pet: visible('pet', effects) })
+    const arrival = deferred()
+    const started = deferred()
+    b.arrival(async () => { started.resolve(); await arrival.promise })
+    const enabling = b.modules.entries.sync(graph(row('pet')))
+    await started.promise
+    const disabling = b.modules.entries.sync(graph())
+    arrival.resolve()
+    await Promise.all([enabling, disabling])
+    expect(effects.mounted).toBe(0)
+    expect(b.modules.loadCache.has('pet')).toBe(false)
+    await b.modules.entries.sync(graph(row('pet')))
+    expect(effects.mounted).toBe(1)
+    expect(b.fetched).toEqual([row('pet').url, row('pet').url])
+  })
+
+  it('registers dynamic dependencies first and retains one still required by an unmanaged entry', async () => {
+    const materialized = vi.fn<(id: string) => void>()
+    const b = await bench(graph(row('existing')), {
+      existing: () => { materialized('existing'); return { apply() {} } },
+      dependency: () => { materialized('dependency'); return { apply() {}, value: 42 } },
+      consumer: (require) => {
+        materialized('consumer')
+        expect(require('dependency/client')).toHaveProperty('value', 42)
+        return { apply() {} }
+      },
+    })
+    await b.modules.entries.sync(graph(row('existing'), row('consumer', 'r0', { external: ['dependency/client'] }), row('dependency')))
+    expect(b.fetched.slice(1)).toEqual([row('dependency').url, row('consumer').url])
+    expect(materialized.mock.calls.map(([id]) => id)).toEqual(['existing', 'consumer', 'dependency'])
+    b.target.load({ id: 'local', factory: require => ({ apply() {}, dep: require('dependency/client') }) })
+    const localId = await b.ctx.loader.create({ name: 'local' })
+    await b.modules.entries.sync(graph(row('existing')))
+    expect(b.modules.loadCache.has('consumer')).toBe(false)
+    expect(b.modules.loadCache.has('dependency')).toBe(true)
+    b.ctx.loader.remove(localId)
+    await b.modules.entries.retry()
+    expect(b.modules.loadCache.has('dependency')).toBe(false)
+    expect(materialized.mock.calls.filter(([id]) => id === 'existing')).toHaveLength(1)
+  })
+
+  it('diagnoses download and apply failures, retries an identical graph and leaves healthy plugins running', async () => {
+    const effects = { mounted: 0, disposed: 0, hits: 0 }
+    let broken = true
+    const b = await bench(graph(row('healthy')), {
+      healthy: visible('healthy', effects),
+      bad: () => ({ apply() { if (broken) throw new Error('apply unavailable') } }),
+    })
+    b.arrival(async () => { throw new Error('download unavailable') })
+    await b.modules.entries.sync(graph(row('healthy'), row('bad')))
+    expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('download unavailable')
+    b.arrival(async () => {})
+    await b.modules.entries.retry()
+    expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('apply unavailable')
+    broken = false
+    await b.modules.entries.retry()
+    expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+    expect(effects.mounted).toBe(1)
+    expect(effects.disposed).toBe(0)
+  })
+
+  it('deduplicates rebuilt and graph revisions and retries after failed code arrival', async () => {
+    const effects = { mounted: 0, disposed: 0, hits: 0 }
+    const b = await bench(graph(row('pet')), { pet: visible('pet', effects) })
+    await b.modules.entries.reload('pet', 'r1')
+    await b.modules.entries.sync(graph(row('pet', 'r1')))
+    expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
+    b.arrival(async () => { throw new Error('offline') })
+    await expect(b.modules.entries.reload('pet', 'r2')).rejects.toThrow('offline')
+    expect(effects.mounted).toBe(2)
+    b.arrival(async () => {})
+    await b.modules.entries.retry()
+    expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
+    expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
+  })
+
+  it('rejects malformed graphs before mutating active entries', async () => {
+    const b = await bench(graph(row('a')), { a: () => ({ apply() {} }) })
+    expect(() => b.modules.entries.sync({ rev: 'bad', entries: [{ id: 'a' }], batches: [] })).toThrow('string id/url/rev')
+    expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['a'])
+    expect(() => b.modules.entries.start(b.ctx.loader, b.modules.manifest)).toThrow('already started')
+  })
+})
+
+
+it('publishes stable local snapshots and contains a failing subscriber', async () => {
+  const b = await bench(graph(), {})
+  const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+  const listener = vi.fn()
+  const removeBad = b.modules.entries.state.subscribe(() => { throw new Error('subscriber') })
+  const remove = b.modules.entries.state.subscribe(listener)
+  expect(b.modules.entries.state.getSnapshot()).toBe(b.modules.entries.state.getSnapshot())
+  await b.modules.entries.retry()
+  expect(listener).toHaveBeenCalledTimes(2)
+  expect(error).toHaveBeenCalled()
+  removeBad()
+  remove()
+  await b.modules.entries.retry()
+  expect(listener).toHaveBeenCalledTimes(2)
+})
+
+it('ignores superseded queued snapshots and rebuilds of absent or unchanged entries', async () => {
+  const b = await bench(graph(), { a: () => ({ apply() {} }) })
+  const first = b.modules.entries.sync(graph(row('a')))
+  const second = b.modules.entries.sync(graph())
+  await Promise.all([first, second])
+  expect(b.fetched).toEqual([])
+  await b.modules.entries.reload('a', 'r1')
+  await b.modules.entries.sync(graph(row('a')))
+  await b.modules.entries.reload('a', 'r0')
+  await b.ctx.fiber.dispose()
+  await b.modules.entries.reload('a', 'r2')
+  await b.modules.entries.sync(graph())
+  expect(b.fetched).toHaveLength(1)
+})
+
+it('reports reconciliation before startup and missing service activation without losing later retries', async () => {
+  const b = await bench(graph(), { pending: () => ({ inject: ['missing'], apply() {} }) }, false)
+  await expect(b.modules.entries.sync(graph())).rejects.toThrow('have not started')
+  await b.modules.entries.start(b.ctx.loader, b.modules.manifest)
+  await b.modules.entries.sync(graph(row('pending')))
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('waiting for activation')
+  b.ctx.provide('missing', {})
+  await b.modules.entries.retry()
+  expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+})
+
+it('retries a materialization failure with the same graph and cleans its partial styles', async () => {
+  let broken = true
+  const b = await bench(graph(), { a: () => {
+    const style = document.createElement('style')
+    style.dataset.plugin = 'a'
+    document.head.append(style)
+    if (broken) throw new Error('factory failed')
+    return { apply() {} }
+  } })
+  await b.modules.entries.sync(graph(row('a')))
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
+  expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(0)
+  await b.modules.entries.retry()
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
+  await b.modules.entries.reload('a', 'r1').catch(() => {})
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
+  broken = false
+  await b.modules.entries.retry()
+  expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+  expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
+})
+
+it('does not finish a stale code replacement after download or asynchronous teardown', async () => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const cleanup = deferred()
+  const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise) })
+  const download = deferred()
+  const started = deferred()
+  b.arrival(async () => { started.resolve(); await download.promise })
+  const rebuilding = b.modules.entries.reload('a', 'r1')
+  await started.promise
+  const snapshot = b.modules.entries.sync(graph(row('a')))
+  download.resolve()
+  await Promise.all([rebuilding, snapshot])
+  expect(effects.mounted).toBe(1)
+  const swapping = b.modules.entries.reload('a', 'r2')
+  await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
+  const disabling = b.modules.entries.sync(graph())
+  cleanup.resolve()
+  await Promise.all([swapping, disabling])
+  expect(effects.mounted).toBe(1)
+  expect(b.modules.loadCache.has('a')).toBe(false)
+})
+
+it('stops an obsolete multi-entry application after awaiting removal', async () => {
+  const cleanup = deferred()
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise), b: () => ({ apply() {} }) })
+  const first = b.modules.entries.sync(graph(row('b')))
+  await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
+  const latest = b.modules.entries.sync(graph())
+  cleanup.resolve()
+  await Promise.all([first, latest])
+  expect(b.fetched).toEqual(['/batch'])
+})
+
+
+it('keeps bootstrap ownership explicit and diagnoses removal without changing entries', async () => {
+  const b = await bench(graph(row('bootstrap')), {})
+  await expect(b.modules.entries.sync(graph())).rejects.toThrow('removing bootstrap module')
+  expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['bootstrap'])
+  expect(b.modules.entries.state.getSnapshot().syncing).toBe(false)
+  await b.modules.entries.sync(graph(row('bootstrap')))
+  expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+})
+
+it('retains unrelated style tags while reporting a failed replacement', async () => {
+  let broken = false
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('a')), { a: () => {
+    if (broken) throw new Error('changed factory failed')
+    return visible('a', effects)()
+  } })
+  const unrelated = document.createElement('style')
+  unrelated.dataset.plugin = 'unrelated'
+  document.head.append(unrelated)
+  broken = true
+  await b.modules.entries.sync(graph(row('a', 'r1')))
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('changed factory failed')
+  expect(unrelated.isConnected).toBe(true)
+})
+
+
+it('keeps unrelated page failures visible when a rebuilt download fails', async () => {
+  const b = await bench(graph(row('a'), row('bad')), {
+    a: () => ({ apply() {} }),
+    bad: () => ({ apply() { throw new Error('bad apply') } }),
+  })
+  await b.modules.entries.retry()
+  b.arrival(async () => { throw new Error('a download') })
+  await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('a download')
+  expect(b.modules.entries.state.getSnapshot().failures.map(failure => failure.id)).toEqual(['bad', 'a'])
+})
+
+it('retains a rejected Loader entry for retry instead of creating an orphan sibling', async () => {
+  let broken = true
+  const b = await bench(graph(), { a: () => broken ? { default: 'invalid' } : { apply() {} } })
+  await b.modules.entries.sync(graph(row('a')))
+  const entry = [...b.ctx.loader.entries()][0]!
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('invalid plugin')
+  expect([...b.ctx.loader.entries()]).toHaveLength(1)
+  broken = false
+  await b.modules.entries.retry()
+  expect([...b.ctx.loader.entries()]).toEqual([entry])
+  expect(entry.fiber?.state).toBe(2)
+})
+
+it('reports Loader import failures and recovers the same entry after the importer recovers', async () => {
+  const b = await bench(graph(), { a: () => ({ apply() {} }) })
+  let broken = true
+  b.ctx.loader.internal = {
+    version: 'client', import: async (id: string) => {
+      if (broken) throw new Error('Loader import failed')
+      return b.modules.import(id)
+    },
+  } as never
+  await b.modules.entries.sync(graph(row('a')))
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('import failed')
+  const entry = [...b.ctx.loader.entries()][0]!
+  broken = false
+  await b.modules.entries.retry()
+  expect(entry.fiber?.state).toBe(2)
+  broken = true
+  await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('import failed')
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.id).toBe('a')
+})
+
+it.each([false, true])('does not mount a materialized factory after a newer removal (rebuild: %s)', async (rebuild) => {
+  let stop: Promise<void> | undefined
+  let materializations = 0
+  let mounted = 0
+  const factories = { a: () => {
+    materializations++
+    if (materializations === (rebuild ? 2 : 1)) {
+      queueMicrotask(() => { stop = b.modules.entries.sync(graph()) })
+    }
+    return { apply() { mounted++ } }
+  } }
+  const b = await bench(graph(), factories)
+  if (rebuild) {
+    // The first materialization remains live until the replacement reaches the import barrier.
+    materializations = -1
+    await b.modules.entries.sync(graph(row('a')))
+    materializations = 1
+    await b.modules.entries.reload('a', 'r1')
+  } else {
+    await b.modules.entries.sync(graph(row('a')))
+  }
+  await stop
+  expect(mounted).toBe(rebuild ? 1 : 0)
+  expect([...b.ctx.loader.entries()]).toHaveLength(0)
+})
+
+it('coalesces an overlapping graph snapshot with the same rebuilt artifact', async () => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('a')), { a: visible('a', effects) })
+  const download = deferred()
+  const started = deferred()
+  b.arrival(async () => { started.resolve(); await download.promise })
+  const rebuilding = b.modules.entries.reload('a', 'r1')
+  await started.promise
+  const syncing = b.modules.entries.sync(graph(row('a', 'r1')))
+  download.resolve()
+  await Promise.all([rebuilding, syncing])
+  expect(b.fetched).toEqual(['/batch', row('a', 'r1').url])
+  expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
+})
+
+it.each(['graph', 'rebuilt'])('replaces a failed factory before entry creation on a new %s revision', async (source) => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => { throw new Error('broken r0 factory') } }
+  const b = await bench(graph(), factories)
+  await b.modules.entries.sync(graph(row('a')))
+  expect([...b.ctx.loader.entries()]).toHaveLength(0)
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('broken r0 factory')
+  factories.a = () => ({ ...visible('a', effects)(), revision: 'r1' })
+  if (source === 'graph') await b.modules.entries.sync(graph(row('a', 'r1')))
+  else await b.modules.entries.reload('a', 'r1')
+  await b.modules.entries.retry()
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
+  expect(await b.modules.import('a')).toHaveProperty('revision', 'r1')
+  expect(document.querySelectorAll('[data-live=a]')).toHaveLength(1)
+  expect(effects.mounted).toBe(1)
+  expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+})
+
+it('replaces a superseded arrival and its cached dependency before mounting the latest code', async () => {
+  const dependency = row('dependency')
+  const consumer = row('consumer', 'r0', { external: ['dependency/client'] })
+  const factories: Record<string, ClientBundleRegistration['factory']> = {
+    dependency: () => ({ apply() {}, revision: 'r0' }),
+    consumer: require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r0' }),
+  }
+  const b = await bench(graph(), factories)
+  const started = deferred()
+  const download = deferred()
+  b.arrival(async (url) => { if (url === consumer.url) { started.resolve(); await download.promise } })
+  const old = b.modules.entries.sync(graph(consumer, dependency))
+  try {
+    await started.promise
+    factories.dependency = () => ({ apply() {}, revision: 'r1' })
+    factories.consumer = require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r1' })
+    const latest = b.modules.entries.sync(graph(row('consumer', 'r1', { external: ['dependency/client'] }), row('dependency', 'r1')))
+    download.resolve()
+    await Promise.all([old, latest])
+    expect(await b.modules.import('consumer')).toMatchObject({ revision: 'r1', dependency: { revision: 'r1' } })
+    expect(b.fetched).toEqual([dependency.url, consumer.url, row('dependency', 'r1').url, row('consumer', 'r1').url])
+    expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
+  } finally {
+    download.resolve()
+    await old
+  }
+})
+
+it.each(['graph', 'rebuilt'])('preserves bootstrap and dependent fibers when a %s requests new bootstrap code', async (source) => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  const b = await bench(graph(row('bootstrap'), row('consumer')), {
+    consumer: () => ({ ...visible('consumer', effects)(), inject: ['modules'] }),
+  })
+  const fibers = [...b.ctx.loader.entries()].map(entry => entry.fiber)
+  const exports = await b.modules.import('bootstrap')
+  if (source === 'graph') await b.modules.entries.sync(graph(row('bootstrap', 'r1'), row('consumer')))
+  else await expect(b.modules.entries.reload('bootstrap', 'r1')).rejects.toThrow('requires a page reload')
+  for (let retry = 0; retry < 2; retry++) {
+    await b.modules.entries.retry()
+    expect(b.modules.entries.state.getSnapshot().failures).toEqual([
+      { id: 'bootstrap', message: 'Error: client-modules: replacing bootstrap module bootstrap requires a page reload' },
+    ])
+  }
+  expect([...b.ctx.loader.entries()].map(entry => entry.fiber)).toEqual(fibers)
+  expect(await b.modules.import('bootstrap')).toBe(exports)
+  expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
+  expect(b.fetched).toEqual(['/batch'])
+})
+
+it('discards a failed arrival target when an uncreated entry receives a newer graph', async () => {
+  const b = await bench(graph(), { a: () => { throw new Error('r0 factory') } })
+  await b.modules.entries.sync(graph(row('a')))
+  b.arrival(async () => { throw new Error('offline r1') })
+  await b.modules.entries.reload('a', 'r1')
+  expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('offline r1')
+  b.arrival(async () => {})
+  await b.modules.entries.sync(graph(row('a', 'r2')))
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r2').url])
+})
+
+it('uses the latest desired revision when a rebuild queues before entry creation', async () => {
+  const b = await bench(graph(), { a: () => ({ apply() {} }) })
+  const adding = b.modules.entries.sync(graph(row('a')))
+  const rebuilding = b.modules.entries.reload('a', 'r1')
+  const latest = b.modules.entries.sync(graph(row('a', 'r2')))
+  await Promise.all([adding, rebuilding, latest])
+  expect(b.fetched).toEqual([row('a', 'r2').url])
+  expect([...b.ctx.loader.entries()]).toHaveLength(1)
+})
+
+it('cleans styles from a materialized factory superseded before its entry is created', async () => {
+  const effects = { mounted: 0, disposed: 0, hits: 0 }
+  let latest: Promise<void> | undefined
+  const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => {
+    const old = visible('a', effects)()
+    queueMicrotask(() => {
+      factories.a = visible('a', effects)
+      latest = b.modules.entries.sync(graph(row('a', 'r1')))
+    })
+    return old
+  } }
+  const b = await bench(graph(), factories)
+  await b.modules.entries.sync(graph(row('a')))
+  await latest
+  expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
+  expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
+  expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
+})

+ 26 - 0
packages/client/modules/tests/loader.client.spec.ts

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import { removeOwnedStyles } from '../src/client/entry-lifecycle.ts'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
@@ -536,6 +537,7 @@ describe('style claiming', () => {
     vi.stubGlobal('document', undefined)
     try {
       await b.loader.import('a', '', {})
+      removeOwnedStyles('a')
     } finally {
       vi.unstubAllGlobals()
     }
@@ -575,3 +577,27 @@ describe('default transport seam', () => {
     expect([...document.querySelectorAll('script')]).toEqual([])
   })
 })
+
+
+it('rejects invalid revision URLs and keeps bootstrap exports pinned under invalidation', () => {
+  const b = bench([row(MODULES_ID), row('a', { url: '/unrevisioned' })])
+  b.loader.invalidate(MODULES_ID)
+  expect(b.loader.loadCache.get(MODULES_ID)?.exports).toBe(bootstrapExports)
+  expect(() =>{  b.loader.invalidate('a', 'next') }).toThrow('has no revision')
+})
+
+it('prefetch skips platform requests, cached dependencies and absent optional inject rows', async () => {
+  const b = bench([
+    row('a'),
+    row('b', { external: ['platform', 'a/client'], inject: ['missing'] }),
+  ], { a: () => ({}), b: () => ({}) }, { seed: { platform: {} } })
+  await b.loader.import('a', '', {})
+  await b.loader.import('b', '', {})
+  expect(b.fetched).toEqual([APPLICATION_URL])
+})
+
+
+it('rejects a wire request with no dynamic row or platform supplier at materialization', async () => {
+  const b = bench([row('a', { external: ['missing'] })], { a: require => ({ value: require('missing') }) })
+  await expect(b.loader.import('a', '', {})).rejects.toThrow('missed the module table')
+})

+ 6 - 5
packages/client/modules/tsconfig.json

@@ -8,10 +8,11 @@
   },
   "include": ["src"],
   "references": [
-    { "path": "../../../vendor/cordis" },
-    { "path": "../../../vendor/loader" },
-    { "path": "../../host/webserver" },
-    { "path": "../../util/package-manifest" },
-    { "path": "../../runtime-diagnostics/invariants" }
+    {"path": "../../../vendor/cordis"},
+    {"path": "../../../vendor/loader"},
+    {"path": "../../host/webserver"},
+    {"path": "../../util/package-manifest"},
+    {"path": "../../runtime-diagnostics/invariants"},
+    {"path": "../store"}
   ]
 }

+ 2 - 2
packages/client/ui-settings-plugin-inventory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md
-README.md: 40b7e41cd2d6953fdf3b9155fd6adff9d9d65391
-README.zh.md: 45f3444ea4fa56fe4a55517eb5d981913c790b15
+README.md: bbc8c373e013da9e921370da3293af9b4808db6a
+README.zh.md: 0f6b99dcc84bf08bcda1afa21e8dd11fe6d23dfa

+ 2 - 0
packages/client/ui-settings-plugin-inventory/README.md

@@ -39,6 +39,8 @@ The switcher is the same selector-pill-plus-menu control the General settings ro
 
 A failed read renders a generic failure state inside the tab; retrying re-runs the lazy `list()` call without exposing transport details.
 
+The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 2 - 0
packages/client/ui-settings-plugin-inventory/README.zh.md

@@ -39,6 +39,8 @@ kind: "package-reference"
 
 读取失败会在标签页内渲染通用失败状态;重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
 
+插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 5 - 2
packages/client/ui-settings-plugin-inventory/package.json

@@ -31,7 +31,8 @@
         "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-ui-agent-preset"
+        "@deepseek-ai/dsh-client-ui-agent-preset",
+        "@deepseek-ai/dsh-client-modules"
       ],
       "platform": "web"
     }
@@ -58,7 +59,9 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-agent-presets": "workspace:^"
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 18 - 1
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -1,4 +1,6 @@
 import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
+import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
 import {
   IconChevronDownOutline14,
@@ -18,6 +20,10 @@ type AgentPresetRow = AgentPresetGroup['rows'][number]
 
 /** Registration-side Remote face used by the section. */
 export interface PluginInventorySettingsTabInjected {
+  /** Page-local module synchronization, independent from the Host inventory. */
+  hooks: { clientSync: ObservableSnapshot<ClientEntryState> }
+  /** Retry the latest client graph without changing the Host composition. */
+  retryClient: () => void
   /** Read a current Host inventory snapshot. */
   list: () => Promise<PluginInventorySnapshot>
   /**
@@ -197,7 +203,10 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab
 }
 
 /** Render the read-only plugin inventory: agent presets first, then the global plane. */
-export function PluginInventorySettingsTab({ list, presetName, t }: PluginInventorySettingsTabProps): ReactNode {
+export function PluginInventorySettingsTab(
+  { list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps,
+): ReactNode {
+  const clientSync = useClientSync(snapshot => snapshot)
   const sectionId = useId()
   const [request, setRequest] = useState(0)
   const [query, setQuery] = useState('')
@@ -375,6 +384,14 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent
 
   return (
     <div className={css.section} aria-busy={state.status === 'loading'}>
+      {clientSync.syncing ? <p className={css.status} role="status">{t('clientSyncing')}</p> : null}
+      {clientSync.failures.length === 0 ? null : (
+        <div className={css.failure} data-client-sync-failure>
+          <p role="alert">{t('clientSyncFailed')}</p>
+          <ul>{clientSync.failures.map(failure => <li key={failure.id}>{failure.id}: {failure.message}</li>)}</ul>
+          <button type="button" disabled={clientSync.syncing} onClick={retryClient}>{t('clientSyncRetry')}</button>
+        </div>
+      )}
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
       {state.status === 'error' ? (
         <div className={css.failure}>

+ 7 - 2
packages/client/ui-settings-plugin-inventory/src/client/index.ts

@@ -1,6 +1,7 @@
 /** Read-only Host plugin inventory registered into Web Settings. */
 
 import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-modules/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -26,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export const NS = 'settings.pluginInventory'
 
 /** Services required by the Settings registration and generated Remote face. */
-export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules']
 
 /** Contribute the lazy inventory tab to the Plugins settings section. */
 export function apply(ctx: ClientContext): void {
@@ -45,7 +46,11 @@ export function apply(ctx: ClientContext): void {
   const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
   const presetName: PluginInventorySettingsTabInjected['presetName'] = preset =>
     presetDisplayText(preset, agentPresetCopy).name
-  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName })
+  const injected = (): PluginInventorySettingsTabInjected => ({
+    list, presetName,
+    hooks: { clientSync: ctx.modules.entries.state },
+    retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) },
+  })
 
   ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
     name: 'settings.plugins.tab',

+ 6 - 0
packages/client/ui-settings-plugin-inventory/src/client/locales.ts

@@ -4,6 +4,9 @@
 export const zh = {
   tab: '插件列表',
   loading: '正在读取插件…',
+  clientSyncing: '正在同步本页面的插件…',
+  clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。',
+  clientSyncRetry: '重试本页面同步',
   error: '暂时无法读取插件。',
   retry: '重试',
   search: '搜索插件',
@@ -47,6 +50,9 @@ export type PluginInventoryLocaleKey = keyof typeof zh
 export const en = {
   tab: 'Plugin list',
   loading: 'Reading plugins…',
+  clientSyncing: 'Syncing plugins on this page…',
+  clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.',
+  clientSyncRetry: 'Retry this page',
   error: 'Plugins are temporarily unavailable.',
   retry: 'Retry',
   search: 'Search plugins',

+ 12 - 2
packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx

@@ -1,4 +1,6 @@
 // @vitest-environment jsdom
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
 import { Context, Service } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup } from '@testing-library/react'
@@ -21,6 +23,8 @@ type ListResult =
 
 async function bench() {
   const ctx = new Context()
+  const retryClient = vi.fn(async () => {})
+  ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader)
   await ctx.plugin(SlotRegistry).await()
   const locale = new LocaleRuntime(ctx)
   ctx.provide('locale', locale)
@@ -33,7 +37,7 @@ async function bench() {
   const list = vi.fn<() => Promise<ListResult>>()
     .mockResolvedValue({ ok: true, value: EMPTY })
   ctx.provide('remote.pluginInventory', { list })
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list }
+  return { ctx, retryClient, slots: ctx.get('slots') as SlotRegistry, locale, list }
 }
 
 function declare(slots: SlotRegistry): () => void {
@@ -49,7 +53,7 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
   })
 
   it('declares only the services used by the Settings Remote contribution', () => {
-    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules'])
   })
 
   it('registers a localized tab without reading the Remote eagerly', async () => {
@@ -65,6 +69,12 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
     expect(b.list).not.toHaveBeenCalled()
 
     const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
+    injected.retryClient()
+    const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
+    b.retryClient.mockRejectedValueOnce(new Error('retry unavailable'))
+    injected.retryClient()
+    await vi.waitFor(() => { expect(retryError).toHaveBeenCalled() })
+    retryError.mockRestore()
     await expect(injected.list()).resolves.toEqual(EMPTY)
     expect(b.list).toHaveBeenCalledOnce()
     b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })

+ 22 - 0
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -1,4 +1,7 @@
 // @vitest-environment jsdom
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
@@ -25,6 +28,8 @@ function props(
     t,
     list,
     presetName,
+    useClientSync: bindSnapshotSelector(createSnapshotStore<ClientEntryState>({ syncing: false, failures: [] })),
+    retryClient: vi.fn(),
   } as PluginInventorySettingsTabProps
 }
 
@@ -378,3 +383,20 @@ describe('PluginInventorySettingsTab', () => {
     await act(async () => { deferredFailure.reject(new Error('late failure')) })
   })
 })
+
+
+it('shows current-page sync errors and retries without re-reading Host inventory', async () => {
+  const list = vi.fn(async () => ({ entries: [] }))
+  const sync = createSnapshotStore<ClientEntryState>({ syncing: true, failures: [] })
+  const retryClient = vi.fn()
+  render(<PluginInventorySettingsTab {...props(list)} useClientSync={bindSnapshotSelector(sync)} retryClient={retryClient} />)
+  expect(screen.getByRole('status').textContent).toContain('Syncing plugins on this page')
+  await waitFor(() => { expect(list).toHaveBeenCalledOnce() })
+  act(() => { sync.set({ syncing: false, failures: [{ id: 'client-addon', message: 'download failed' }] }) })
+  expect(screen.getByText('client-addon: download failed')).toBeTruthy()
+  fireEvent.click(screen.getByRole('button', { name: 'Retry this page' }))
+  expect(retryClient).toHaveBeenCalledOnce()
+  expect(list).toHaveBeenCalledOnce()
+  act(() => { sync.set({ syncing: false, failures: [] }) })
+  expect(screen.queryByRole('alert')).toBeNull()
+})

+ 6 - 0
packages/client/ui-settings-plugin-inventory/tsconfig.json

@@ -34,6 +34,12 @@
     },
     {
       "path": "../ui-slots"
+    },
+    {
+      "path": "../modules"
+    },
+    {
+      "path": "../store"
     }
   ]
 }

+ 2 - 2
packages/client/web/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/web/README.md
-README.md: c994f725b7a0eec1a694f1e544a0f979f5fcc31d
-README.zh.md: 5fc5d574961500e781b670877295b7efb187b3fa
+README.md: d3258b0f6f96fb84a5e58519f3e3313ad4bb022b
+README.zh.md: 7c66b2e4143505000b72ca097a341139b1c30c84

+ 2 - 0
packages/client/web/README.md

@@ -69,6 +69,8 @@ The kernel owns exactly three things: the module system, the Cordis Loader, and
 
 The boot page is plain DOM with local CSS whose fallback fonts and colors match the theme tokens that arrive during loading. `internal/status` events drive one spinner node and per-entry labels; hydration preserves the node and animation phase through the application commit, and `fail()` renders the thrown reason. React mounting, slot rendering, and assembly live in `ui-renderer`; `ui-layout` owns the assembled browser-title projection.
 
+The boot kernel delegates manifest entry creation to Client Modules so live graph synchronization owns the same entry identities after startup. The initial activation audit remains strict; later page-local failures appear in Settings → Plugins → Plugin list.
+
 ### Source map
 
 | File | Role |

+ 2 - 0
packages/client/web/README.zh.md

@@ -69,6 +69,8 @@ kind: "package-library"
 
 启动页是原生 DOM 加本地 CSS,其回退字体与颜色匹配加载期间到达的主题 token。`internal/status` 事件驱动一个 spinner 节点与逐 entry 标签;hydrate 会保留该节点与动画相位直到应用提交,`fail()` 渲染抛出的原因。React 挂载、slot 渲染与应用组装位于 `ui-renderer`;`ui-layout` 拥有组装后的浏览器标题投影。
 
+启动内核把清单条目创建交给 Client Modules,使启动后的动态图同步继续持有相同的条目身份。初始激活审计仍然严格;后续页面本地失败显示在「设置 → 插件 → 插件列表」。
+
 ### 源码地图
 
 | 文件 | 职责 |

+ 5 - 5
packages/client/web/src/boot-client.ts

@@ -45,11 +45,11 @@ export async function bootClient(options: ClientBootOptions): Promise<void> {
   })
 
   const rows = manifest.plugins.map(row => row.id)
-  await Promise.all(rows.map(async (name) => {
-    onEntryState?.(name, 'loading')
-    const id = await loader.create({ name })
-    if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
-  }))
+  for (const name of rows) onEntryState?.(name, 'loading')
+  await options.modules.entries.start(loader, manifest)
+  for (const entry of loader.entries()) {
+    if (entry.fiber === undefined) onEntryState?.(entry.options.name, 'failed')
+  }
 
   await loader.await()
   assertEntriesActive(ctx)

+ 2 - 2
packages/test-support/client-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md
-README.md: 014bcbf8bc0ed9be596e2a20c07f57c80aaaf7dc
-README.zh.md: a14a5281c58a5ea4a8a49e1a826397ae91e5f115
+README.md: 819892dfb5b433c6b4dcbe40a445e974137eff71
+README.zh.md: ee63f4d930410f7f5069be9027327559249b56ca

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/test-support/client-runtime/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/test-support/client-runtime/README.zh.md


+ 2 - 2
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -6,7 +6,7 @@
  */
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
-import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client'
+import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-modules/client'
 import {
   installConnection,
   type ConnectionHandle,
@@ -262,7 +262,7 @@ export class TestClient {
   }
 
   /**
-   * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then
+   * Rebuild one Loader entry: Client Modules' registry-first fiber teardown, then
    * `entry.refresh()`. Each client's module table retains its own instance-bound
    * Connection plugin, so reloads do not coordinate through process globals.
    * Requires a live client: after `dispose()` the Loader holds no entries and

+ 9 - 0
pnpm-lock.yaml

@@ -2303,6 +2303,9 @@ importers:
       '@deepseek-ai/cordis-plugin-loader':
         specifier: workspace:^
         version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../host/webserver
@@ -3843,6 +3846,12 @@ importers:
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../modules
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
       '@deepseek-ai/dsh-client-test-runtime':
         specifier: workspace:^
         version: link:../../test-support/client-runtime

+ 1 - 0
tsconfig.host.json

@@ -38,6 +38,7 @@
     "apps/web/tests/details-session-lifecycle.e2e.ts",
     "apps/web/tests/document-preview.e2e.ts",
     "apps/web/tests/plugin-config.e2e.ts",
+    "apps/web/tests/client-plugin-live.e2e.ts",
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/deepseek-messages-settings.e2e.ts",

+ 0 - 2
vitest.config.ts

@@ -284,8 +284,6 @@ export default defineConfig({
         'packages/experimental/inspector/src/shared/bridge/messages/runtime/{command-codec,console-frames,frames,value-codec}.ts',
         'packages/experimental/inspector/src/shared/bridge/messages/sources/{codec,frames}.ts',
         'packages/experimental/inspector/src/worker/inspection/{cordis-store,query-router,realm-store}.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.

Неке датотеке нису приказане због велике количине промена