Просмотр исходного кода

feat(web): apply client plugin changes without reloading

Yichen Jiang 3 дней назад
Родитель
Сommit
fb0fb48033
59 измененных файлов с 1268 добавлено и 231 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 6 0
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 9 3
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 131 0
      apps/web/tests/client-plugin-live.e2e.ts
  5. 1 0
      apps/web/tests/expected/client-plugin-live/enabled.expected.md
  6. 31 0
      apps/web/tests/fixtures/plugins/fixture-live-client/client.js
  7. 3 0
      apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml
  8. 2 0
      apps/web/tests/fixtures/plugins/fixture-live-client/index.js
  9. 13 0
      apps/web/tests/fixtures/plugins/fixture-live-client/package.json
  10. 1 0
      apps/web/tsconfig.json
  11. 2 2
      docs/config-catalog.i18n.yaml
  12. 1 1
      docs/config-catalog.md
  13. 1 1
      docs/config-catalog.zh.md
  14. 2 2
      docs/subsystems/client-modules.i18n.yaml
  15. 1 1
      docs/subsystems/client-modules.md
  16. 1 1
      docs/subsystems/client-modules.zh.md
  17. 2 2
      packages/client/hmr/README.i18n.yaml
  18. 8 8
      packages/client/hmr/README.md
  19. 8 8
      packages/client/hmr/README.zh.md
  20. 1 1
      packages/client/hmr/package.json
  21. 11 155
      packages/client/hmr/src/client/index.ts
  22. 5 5
      packages/client/hmr/src/events.ts
  23. 40 4
      packages/client/hmr/src/index.ts
  24. 80 0
      packages/client/hmr/tests/node-half.client.spec.ts
  25. 42 0
      packages/client/hmr/tests/transport.client.spec.ts
  26. 3 1
      packages/client/hmr/tsconfig.json
  27. 2 2
      packages/client/modules/README.i18n.yaml
  28. 9 1
      packages/client/modules/README.md
  29. 9 1
      packages/client/modules/README.zh.md
  30. 2 1
      packages/client/modules/package.json
  31. 230 0
      packages/client/modules/src/client/entries.ts
  32. 28 0
      packages/client/modules/src/client/entry-lifecycle.ts
  33. 2 0
      packages/client/modules/src/client/index.ts
  34. 3 0
      packages/client/modules/src/client/manifest.ts
  35. 44 2
      packages/client/modules/src/client/system.ts
  36. 393 0
      packages/client/modules/tests/entries.client.spec.ts
  37. 26 0
      packages/client/modules/tests/loader.client.spec.ts
  38. 6 5
      packages/client/modules/tsconfig.json
  39. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  40. 2 0
      packages/client/ui-settings-plugin-inventory/README.md
  41. 2 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  42. 5 2
      packages/client/ui-settings-plugin-inventory/package.json
  43. 18 1
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  44. 7 2
      packages/client/ui-settings-plugin-inventory/src/client/index.ts
  45. 6 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  46. 12 2
      packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
  47. 22 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  48. 6 0
      packages/client/ui-settings-plugin-inventory/tsconfig.json
  49. 2 2
      packages/client/web/README.i18n.yaml
  50. 2 0
      packages/client/web/README.md
  51. 2 0
      packages/client/web/README.zh.md
  52. 5 5
      packages/client/web/src/boot-client.ts
  53. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  54. 0 0
      packages/test-support/client-runtime/README.md
  55. 0 0
      packages/test-support/client-runtime/README.zh.md
  56. 2 2
      packages/test-support/client-runtime/src/assembly/test-client.ts
  57. 9 0
      pnpm-lock.yaml
  58. 1 0
      tsconfig.host.json
  59. 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: 924459599a63070fc4a3cc89d32ea0e18b936dc2
+2026-07-23-client-plugin-loading-model.zh.md: ab8a19ee92601e6e5038ed764802084cd8082b6f

Разница между файлами не показана из-за своего большого размера
+ 6 - 0
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


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

@@ -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 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。
+
+Host SSE 适配器等待 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。
 
-浏览器侧,驱动插件每帧重载一个插件,串行执行
+浏览器侧的传输把代码替换交给负责图对账的同一个 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 本身重新装载——行为正确,但代价较高。
 
-支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈
+重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠
 
 ## 包归属
 

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

@@ -0,0 +1,131 @@
+/** 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 { chromium, type Page } from 'playwright'
+import { expect, it, onTestFailed } 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)

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

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

+ 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: 134926329e9320e38b6370e45138cbfccb180baa
-config-catalog.zh.md: 60cfa69460173ddfd587a86349280c00486ec80a
+config-catalog.md: 44b5ec950dc69091bd2b31bbe94b639d6fa99e85
+config-catalog.zh.md: c4587edfcb0b6ec04db931820c0920a69555d922

+ 1 - 1
docs/config-catalog.md

@@ -428,7 +428,7 @@ Source: [`packages/client/connection/src/index.ts:72`](../packages/client/connec
 
 ## `@deepseek-ai/dsh-client-hmr`
 
-Requires: `clientModules` · `webServer`
+Requires: `clientModules` · `webServer` · `loader`
 
 ```ts config-catalog
 /** Plugin config, validated by the same-named schemastery schema. */

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

@@ -430,7 +430,7 @@ export interface ConnectionRecoveryConfig {
 
 ## `@deepseek-ai/dsh-client-hmr`
 
-需要:`clientModules` · `webServer`
+需要:`clientModules` · `webServer` · `loader`
 
 ```ts config-catalog
 /** Plugin config, validated by the same-named schemastery schema. */

+ 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: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0
-client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27
+client-modules.md: 73246153466e716a13a90d5c63e10c139d00974e
+client-modules.zh.md: babb205e409b75ffe0f49095baa08dba18019ea7

+ 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. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev 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 rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. 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 waits for Loader imports, activation and captured removed-fiber cleanup before publication, and reconnect sends the current full graph. Its artifact poll separately reports rebuilt revisions. 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 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件
+随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 等待 Loader 导入、激活及已捕获的被移除 fiber 清理完成后再发布,重连会发送当前完整图。产物轮询另外报告重建 revision。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: e0e00cca9f3856ab68a22b1de9b93fe8abe249ee
+README.zh.md: 31bb5bfd103bb342d5d7af710afa7350f40c7c80

+ 8 - 8
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
 
@@ -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 broadcasts the full current graph after Loader imports, activation and captured removed-fiber cleanup settle, including on connection and reconnection. Dense graph notifications during one recomposition therefore do not publish intermediate rosters. Artifact polling still reports rebuilt revisions; unchanged artifacts require no content read. The browser half delegates both frame kinds to the page-owned controller in Client Modules.
 
 ### 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.
+No rollback: failed imports and activation remain visible as page-local synchronization errors. Settings → Plugins → Plugin list retries the latest graph, even when its revision is unchanged; 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>
@@ -115,7 +115,7 @@ These limits define what the reload driver does not preserve or restore. They ar
 
 - **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.
+- **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

+ 8 - 8
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 还会提供代码重建。停用此传输会停止向已打开的页面交付图更新
 
 ### 启动重载链路
 
@@ -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`。它在 Loader 导入、激活及已捕获的被移除 fiber 清理完成后广播当前完整图,连接及重连也走这一路径。因此,同一次重组中的密集图通知不会发布中间清单。产物轮询仍会报告重建 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。两者都会输出醒目的错误日志
+不回滚:导入与激活失败会显示为当前页面的同步错误。「设置 → 插件 → 插件列表」会针对最新图重试,即使其 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>
@@ -115,7 +115,7 @@ fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方
 
 - **重载有意保持粗粒度**——全新 fiber 与全新组件;被重载插件内的 React 状态会丢失,而数据层(连接 fiber、运行时 fiber、Session 对象)不受影响。react-refresh 级状态保留与重新执行 bundle 冲突,因此有意排除。
 - **失败时不回滚**——失败的重载会让该 entry 保持 FAILED 并在 loader 状态投影中可见;系统不会自动恢复先前 bundle。
-- **重建帧不会替换启动图**——每个帧都携带单资源 combo 重载所需的插件产物 revision;页面重载时才接收重新组合的启动图
+- **仅负责 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.5-rc.2",
   "publishConfig": {
     "access": "public"

+ 11 - 155
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'
@@ -76,97 +13,16 @@ export const name = 'client-hmr'
 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()
-  }
-}
-
-/**
- * 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' }

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

@@ -10,7 +10,7 @@
  */
 import { statSync } from 'node:fs'
 import type { ServerResponse } from 'node:http'
-import type { Context } from '@deepseek-ai/cordis'
+import type { Context, Fiber } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 // Type imports carry the clientModules/webServer Context merges.
 import type { ClientArtifactBaseline } from '@deepseek-ai/dsh-client-modules'
@@ -24,8 +24,8 @@ export { EVENTS_ENDPOINT } from './events.ts'
 /** Cordis plugin name. */
 export const name = 'client-hmr'
 
-/** Required services: the web plugin table and the route registry. */
-export const inject = ['clientModules', 'webServer']
+/** Required services: the client graph, Web route registry and Loader settlement. */
+export const inject = ['clientModules', 'webServer', 'loader']
 
 /** Plugin config, validated by the same-named schemastery schema. */
 export interface Config {
@@ -158,6 +158,39 @@ export function apply(ctx: Context, config: Config): void {
   // --- /plugins/events SSE channel ----------------------------------------
   const connections = new Set<ServerResponse>()
 
+  const disposing = new Set<Fiber>()
+  let closed = false
+  let publishing: Promise<void> | undefined
+  let dirty = false
+  const publishGraph = (): void => {
+    dirty = true
+    if (publishing !== undefined) return
+    publishing = (async () => {
+      do {
+        dirty = false
+        await ctx.loader.await()
+        for (const fiber of disposing) {
+          while (fiber.inertia !== undefined) await fiber.inertia
+          disposing.delete(fiber)
+        }
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- graph callbacks set dirty while Loader settlement yields.
+      } while (dirty && !closed)
+      if (closed) return
+      const line = sseData({ type: 'graph', graph: ctx.clientModules.graph() })
+      for (const res of connections) res.write(line)
+    })().catch((error: unknown) => { ctx.logger.error(error) }).finally(() => {
+      publishing = undefined
+      if (dirty && !closed) publishGraph()
+    })
+  }
+
+  ctx.on('internal/plugin', (fiber) => {
+    if (fiber.entry === undefined || fiber.uid !== null) return
+    // Removed fibers disappear from Loader.getTasks() before their effects finish.
+    disposing.add(fiber)
+    publishGraph()
+  })
+
   const connect = (res: ServerResponse): void => {
     res.writeHead(200, {
       'content-type': 'text/event-stream',
@@ -167,8 +200,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)
+    publishGraph()
     res.on('close', () => { connections.delete(res) })
   }
 
@@ -187,11 +220,14 @@ 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 () => {
+      closed = true
+      unsubscribeGraph()
       unsubscribe()
       disposeRoute()
       for (const res of connections) res.destroy()

+ 80 - 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'
@@ -88,6 +91,7 @@ function fakeHttpServer(routes: WebRoute[]): WebServer {
 
 async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
   const ctx = new Context()
+  await ctx.plugin(Loader)
   ctx.provide('clientModules', clientModuleHost)
   ctx.provide('webServer', webServer)
   const fiber = ctx.plugin(
@@ -226,3 +230,79 @@ describe('hmr node half', () => {
     await fiber.dispose()
   })
 })
+
+
+it('publishes settled graphs, drains removed fibers and reconnects without intermediate rosters', 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 })
+  ctx.loader.internal = { version: 'client', import: async () => ({
+    apply: async (pluginCtx: Context) => {
+      pluginCtx.effect(() => () => cleanup)
+      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()
+    rows.clear()
+    host.fireGraphChanged()
+    await Promise.resolve()
+    rows.set('a', bundle)
+    host.fireGraphChanged()
+    expect(first.lines).toEqual([': connected\n\n'])
+    release()
+    await vi.waitFor(() => { 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()
+    await vi.waitFor(() => { expect(second.lines).toHaveLength(2) })
+    expect(second.lines[1]).toBe(first.lines[1])
+    first.response.emit('close')
+    ctx.loader.remove(entryId)
+    rows.clear()
+    host.fireGraphChanged()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(second.lines).toHaveLength(2)
+    cleaned()
+    await vi.waitFor(() => { expect(second.lines).toHaveLength(3) })
+    expect((JSON.parse(second.lines[2]!.slice(6)) as { graph: WebBootGraph }).graph.entries).toEqual([])
+    await fiber.dispose()
+    host.fireGraphChanged()
+    await Promise.resolve()
+    expect(second.lines).toHaveLength(3)
+    expect(second.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 } 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 })
+  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: c83796fc8d6c364b9fad6d44e25316e2c2f7e7af
-README.zh.md: 9ffb68ebc07b5970edc45a54c4cfde7cdf4ef7d8
+README.md: 2b5aa0e2e3a0a7d591fc573f3d4d70404e759b2b
+README.zh.md: d2a46283b50baab3a5ee9d399df87a0cfa4910e0

+ 9 - 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 a newer graph arrives. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. 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 |
@@ -118,7 +126,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 the bootstrap requires a page reload; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
 - **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, generated one-resource response, and current startup combo responses in memory; HMR additionally retains one prior startup generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.
 
 <a id="dev-note"></a>

+ 9 - 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。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
+
 ### 源码索引
 
 | 文件 | 职责 |
@@ -118,7 +126,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
 这些限制说明模块系统不做什么。它们是当前包约束,不是任务积压。
 
 - **有意采用扁平模块图**——每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
-- **自身不维护卸载记录**——样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id
+- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除 bootstrap 需要刷新页面;替换包代码及其所有现有消费者不属于普通启停同步
 - **快照式提供会保留产物字节**——Host 在内存中保留每个 bundle、可选 source map、生成的单资源响应和当前启动 combo 响应;HMR 还会保留上一代启动响应。内存会随已组合客户端产物增长为数份副本,以换取不可变响应和一代竞态容忍。
 
 <a id="dev-note"></a>

+ 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",

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

@@ -0,0 +1,230 @@
+/** 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 }[]
+}
+
+/** Internal capabilities owned by the module table, called only after serialized entry operations. */
+interface ModuleIndex {
+  update(manifest: BootManifest): 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
+
+/** 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. A later snapshot prevents an older download from mounting.
+   * @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> {
+    this.desired = parseBootManifest(graph)
+    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.
+   * @param id - Package id from a rebuilt frame.
+   * @param rev - Opaque revision selecting the rebuilt artifact.
+   * @returns after replacement settles; failure rejects and is exposed in page diagnostics.
+   */
+  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 () => {
+      if (this.stopped || !this.desired.modules.some(row => row.id === id)) return
+      const entry = this.managed.get(id)
+      if (entry === undefined || 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) {
+        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.modules.invalidate(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)
+    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,

+ 3 - 0
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' {
@@ -373,6 +374,8 @@ export interface ClientModuleLoader {
   version: 'client'
   /** Parsed Host boot graph shared with the web entry after module-system creation. */
   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>
   /**

+ 44 - 2
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,7 +62,8 @@ 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>
@@ -81,11 +84,14 @@ export class ClientModuleSystem implements ClientModuleLoader {
    */
   constructor(options: ClientModuleSystemOptions) {
     this.manifest = options.manifest
+    this.entries = new ClientEntries(this, {
+      update: (manifest) => { this.updateManifest(manifest) },
+      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)
     }
 
@@ -186,6 +192,9 @@ export class ClientModuleSystem implements ClientModuleLoader {
       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,6 +246,39 @@ export class ClientModuleSystem implements ClientModuleLoader {
     await this.arriveGraphRow(row)
   }
 
+  /** Replace descriptors without invalidating live factories; subsequent arrivals use individual resources. */
+  private updateManifest(manifest: BootManifest): 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`)
+      }
+    }
+    for (const row of manifest.modules) this.graphRows.set(row.id, { ...row, initialUrl: row.url })
+    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

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

@@ -0,0 +1,393 @@
+// @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 { 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: { apply() {} } }, options),
+  }
+  let arrival: (url: string) => Promise<void> = async () => {}
+  const modules = target.create({
+    boot: initial, staticModules: {},
+    loadBundle: async (url) => {
+      fetched.push(url)
+      await arrival(url)
+      const ids = url === '/batch' ? initial.entries.map(row => row.id) : [url.split('??')[1]!.split('/client.js')[0]!]
+      for (const id of ids) target.load({ id, factory: factories[id]! })
+    },
+  })
+  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)
+})

+ 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: 7bdcec9e2a964b746cbd808497f9222e2b4150fa
-README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337
+README.md: 95742491aba7eee868f8e1ce6fede537e2f68384
+README.zh.md: 84970c1cffc43f431a7e6563502d056d64c1c952

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

@@ -67,6 +67,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

@@ -67,6 +67,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.

Некоторые файлы не были показаны из-за большого количества измененных файлов