Parcourir la source

fix(plugin-manager): keep package operations outside HMR

Turtle il y a 1 semaine
Parent
commit
08021a16d8
31 fichiers modifiés avec 280 ajouts et 198 suppressions
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  4. 2 2
      apps/cli/tests/built-bin.e2e.ts
  5. 2 2
      docs/config-catalog.i18n.yaml
  6. 1 1
      docs/config-catalog.md
  7. 1 1
      docs/config-catalog.zh.md
  8. 2 2
      docs/event-producer-consumer.i18n.yaml
  9. 2 3
      docs/event-producer-consumer.md
  10. 2 3
      docs/event-producer-consumer.zh.md
  11. 2 2
      docs/subsystems/boot.i18n.yaml
  12. 0 16
      docs/subsystems/boot.md
  13. 0 16
      docs/subsystems/boot.zh.md
  14. 2 2
      packages/boot/hmr/README.i18n.yaml
  15. 4 4
      packages/boot/hmr/README.md
  16. 4 4
      packages/boot/hmr/README.zh.md
  17. 3 3
      packages/boot/hmr/package.json
  18. 26 29
      packages/boot/hmr/src/index.ts
  19. 11 24
      packages/boot/hmr/tests/coordination.spec.ts
  20. 53 17
      packages/boot/hmr/tests/profile.spec.ts
  21. 17 0
      packages/boot/hmr/tests/watch-config.spec.ts
  22. 0 3
      packages/boot/hmr/tsconfig.json
  23. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  24. 2 2
      packages/boot/plugin-manager/README.md
  25. 2 2
      packages/boot/plugin-manager/README.zh.md
  26. 37 27
      packages/boot/plugin-manager/src/index.ts
  27. 7 3
      packages/boot/plugin-manager/src/patch.ts
  28. 57 2
      packages/boot/plugin-manager/tests/manager.spec.ts
  29. 30 9
      packages/boot/plugin-manager/tests/patch.spec.ts
  30. 0 8
      packages/extensions/tool-cordis/src/api-catalog.ts
  31. 3 3
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.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-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: 5f2a0fb757d29d88c90509dc17e73b6be91877ff
-2026-09-14-current-profile-plugin-management.zh.md: c51f4b7ed8aff0b1e259e4384ecc19c07e123ca5
+2026-09-14-current-profile-plugin-management.md: f92337de4b3b38442ce6e875ebcd1b8c8ccbb01a
+2026-09-14-current-profile-plugin-management.zh.md: 645775fd8b2735d46887e1ddbea1ebeb66b616a1

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -10,11 +10,11 @@ Web and agent controls need to change a running profile without creating an inde
 
 ## Decision
 
-[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. HMR registers the profile watches and `hmr/before-reload` file-lock wrapper during its own initialization, then waits for application readiness before processing edits. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
+[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and manager configuration changes through one queue. HMR registers the profile watches during its own initialization, then waits for application readiness before processing edits. Manifest notifications compare only the ordered bundle list; dependency-only changes do not trigger a configuration reload. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Pnpm runs outside `hmr.runExclusive()`; only configuration changes and Loader updates enter that queue. HMR does not acquire the package writer lock, so installation cannot block unrelated file-driven configuration changes. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
 
 Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states.
 
-Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
+Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
 
 This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -10,11 +10,11 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 ## 决策
 
-[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。HMR 在自身初始化时注册 profile 监听与 `hmr/before-reload` 文件锁包装,等待应用就绪后再处理编辑。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
+[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与管理器配置变更。HMR 在自身初始化时注册 profile 监听,等待应用就绪后再处理编辑。manifest 通知只比较有序组合包列表;仅依赖字段变化不会触发配置重载。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。pnpm 在 `hmr.runExclusive()` 外执行;只有配置变更和 Loader 更新进入该队列。HMR 不获取包操作写锁,因此安装不会阻塞其他由文件变化触发的配置更新。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
 
 配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
 
-profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
 
 这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 

+ 2 - 2
apps/cli/tests/built-bin.e2e.ts

@@ -933,9 +933,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
           `  writeFileSync(${JSON.stringify(mounted)}, 'mounted')\n  let active = true`))
         await writeFileAtomic(patch, hmrPatch
           + '- id: profile-lifecycle-fixture\n  config:\n    generation: configuration-reloaded\n', { mode: 0o600 })
+        await waitForFile(mounted)
+        await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded') }, { timeout: SPAWN_TIMEOUT_MS })
       })
-      await waitForFile(mounted)
-      await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded') }, { timeout: SPAWN_TIMEOUT_MS })
       const replacedAgain = join(fixture.home, 'module-reloaded-again')
       writeFileSync(source, original.replace('  let active = true',
         `  writeFileSync(${JSON.stringify(replacedAgain)}, 'mounted')\n  let active = true`))

+ 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: 6973693f4fc0c7d6c6e11b41cfad58a33bd720fd
-config-catalog.zh.md: 99a614bd879c4d1e99c0f850da58dad70c5ac213
+config-catalog.md: 820f49fd9235a48b8e633781381655eb188780a7
+config-catalog.zh.md: 94d484a02d1d3e27081585b7a4575772aaadfc84

+ 1 - 1
docs/config-catalog.md

@@ -935,7 +935,7 @@ export interface HmrConfig extends ChokidarOptions {
 
 Depends on: `ChokidarOptions` (`chokidar`)
 
-Source: [`packages/boot/hmr/src/index.ts:57`](../packages/boot/hmr/src/index.ts)
+Source: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 

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

@@ -937,7 +937,7 @@ export interface HmrConfig extends ChokidarOptions {
 
 依赖: `ChokidarOptions` (`chokidar`)
 
-来源: [`packages/boot/hmr/src/index.ts:54`](../packages/boot/hmr/src/index.ts)
+来源: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 045ac6ac05fecbdc9bb8a1381fe15d965d8f9a8e
-event-producer-consumer.zh.md: 71cd6f8ea442b58c420ceebfbf7b770de9c2f80f
+event-producer-consumer.md: c4e3d1a4099e4c8e420a9ac46e8d859e4e96e3a4
+event-producer-consumer.zh.md: a7be9fe1f9a021e0b06b29ac0e43fa227189953d

+ 2 - 3
docs/event-producer-consumer.md

@@ -45,9 +45,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | [`hmr`](../packages/boot/hmr) |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:41`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |

+ 2 - 3
docs/event-producer-consumer.zh.md

@@ -47,9 +47,8 @@
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | [`hmr`](../packages/boot/hmr) |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:41`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |

+ 2 - 2
docs/subsystems/boot.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/boot.md
-boot.md: 63240fdb2001095a475ed13e270340527a485d08
-boot.zh.md: 509d41cc4de46d64b9ebab4a23a8f9a09cd5551d
+boot.md: b2507672c3728ebd426b089f4de33f2a83fcabee
+boot.zh.md: 1395b6de08f871e757a167ae3d655bcf41e36de8

+ 0 - 16
docs/subsystems/boot.md

@@ -112,22 +112,6 @@ Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/ap
 
 ### `hmr/*` events
 
-<a id="hmrbefore-reload--waterfall"></a>
-
-#### `hmr/before-reload` — waterfall
-
-Acquire application-owned exclusion before an automatic reload.
-
-```ts cordis-catalog
-/** Acquire application-owned exclusion before an automatic reload.
- * @mode waterfall
- * @param next Runs the remaining lock providers and reload; listeners must await it.
- */
-'hmr/before-reload'(next: () => Promise<void>): Promise<void>
-```
-
-Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
-
 <a id="hmrchange--emit"></a>
 
 #### `hmr/change` — emit

+ 0 - 16
docs/subsystems/boot.zh.md

@@ -112,22 +112,6 @@ Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/ap
 
 ### `hmr/*` events
 
-<a id="hmrbefore-reload--waterfall"></a>
-
-#### `hmr/before-reload` — waterfall
-
-Acquire application-owned exclusion before an automatic reload.
-
-```ts cordis-catalog
-/** Acquire application-owned exclusion before an automatic reload.
- * @mode waterfall
- * @param next Runs the remaining lock providers and reload; listeners must await it.
- */
-'hmr/before-reload'(next: () => Promise<void>): Promise<void>
-```
-
-Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
-
 <a id="hmrchange--emit"></a>
 
 #### `hmr/change` — emit

+ 2 - 2
packages/boot/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/boot/hmr/README.md
-README.md: 0e90b39778ea3ba38928afc1823b6c8d24dde385
-README.zh.md: ec32478eda821152a128d54eac0d2aa0a17c9815
+README.md: 735b37c41e50c6a1f663cbfa0cde42f7250ec415
+README.zh.md: b0d3196913cb11e434b397350c81ad1bb45dc0c6

+ 4 - 4
packages/boot/hmr/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Reload plugin code and profile configuration without overlapping package operations."
+description: "Reload plugin code and profile configuration through one coordinated queue."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Reload plugin source and configuration while an application is running. Module replacements, Include refreshes and registered profile-file handlers share one queue with package mutations. Existing Cordis HMR configuration and events remain available under `ctx.hmr`.
+Reload plugin source and configuration while an application is running. Module replacements, Include refreshes and profile configuration changes share one queue. Package installation runs outside that queue. Existing Cordis HMR configuration and events remain available under `ctx.hmr`.
 
 ## Table of Contents
 
@@ -54,9 +54,9 @@ Chokidar options, including polling, retain their existing meaning. Exact config
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes caller-owned mutations with automatic reloads and rejects nested transactions. HMR registers a `hmr/before-reload` listener that holds the profile file lock around every automatic reload; callers performing mutations acquire their file lock inside `runExclusive()` in the same order. File events received during a transaction are processed afterward.
+`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward.
 
-App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Unknown-file notifications do not acquire reload locks, so lock-file events cannot trigger another lock acquisition. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
+App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
 
 Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
 

+ 4 - 4
packages/boot/hmr/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "热重载插件代码和 profile 配置,并与包操作互斥执行。"
+description: "通过统一协调队列热重载插件代码和 profile 配置。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在应用运行期间重载插件源码和配置。模块替换、Include 刷新与已注册的 profile 文件处理器和包修改共用一个队列。`ctx.hmr` 保留现有 Cordis HMR 的配置和事件。
+在应用运行期间重载插件源码和配置。模块替换、Include 刷新与 profile 配置变更共用一个队列。包安装在该队列之外执行。`ctx.hmr` 保留现有 Cordis HMR 的配置和事件。
 
 ## 目录
 
@@ -54,9 +54,9 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
 <details>
 <summary>实现细节——点击展开</summary>
 
-`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将调用方的修改与自动重载串行化,并拒绝嵌套事务。HMR 注册 `hmr/before-reload` 监听器,在每次自动重载期间持有 profile 文件锁;主动修改的调用方在 `runExclusive()` 内以相同顺序取得文件锁。事务期间收到的文件事件在事务结束后处理。
+`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。
 
-App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。未知文件通知不获取重载锁,因此锁文件事件不会触发下一次取锁。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
+App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
 
 被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
 

+ 3 - 3
packages/boot/hmr/package.json

@@ -39,8 +39,7 @@
     "@babel/code-frame": "^7.29.0",
     "chokidar": "^4.0.3",
     "picomatch": "^4.0.3",
-    "@deepseek-ai/schemastery": "workspace:^",
-    "@deepseek-ai/dsh-atomic-write": "workspace:^"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
     "@types/babel__code-frame": "^7.27.0",
@@ -51,6 +50,7 @@
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
-    "@deepseek-ai/dsh-cmdline": "workspace:^"
+    "@deepseek-ai/dsh-cmdline": "workspace:^",
+    "@deepseek-ai/dsh-atomic-write": "workspace:^"
   }
 }

+ 26 - 29
packages/boot/hmr/src/index.ts

@@ -1,4 +1,4 @@
-/** Module and configuration reloads serialized with application package operations. */
+/** Serialized module and profile-configuration reloads. */
 import { AsyncLocalStorage } from 'node:async_hooks'
 import { watchConfig as watchExactConfig } from './watch-config.ts'
 import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
@@ -7,8 +7,7 @@ import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
 import { basename, dirname, join, relative, resolve } from 'node:path'
 import { readFileSync, realpathSync } from 'node:fs'
-import { readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'
-import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { readProfileManifest, readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-cmdline'
 import { handleError } from './error.ts'
 import type {} from '@deepseek-ai/cordis-plugin-timer'
@@ -24,11 +23,6 @@ declare module '@deepseek-ai/cordis' {
   }
 
   interface Events {
-    /** Acquire application-owned exclusion before an automatic reload.
-     * @mode waterfall
-     * @param next Runs the remaining lock providers and reload; listeners must await it.
-     */
-    'hmr/before-reload'(next: () => Promise<void>): Promise<void>
     /** A watched file has no module or configuration handler.
      * @mode emit
      * @param url Canonical file URL.
@@ -154,7 +148,7 @@ class Hmr extends Service {
 
   private runReload(operation: () => Promise<void>): Promise<void> {
     return this.runExclusive(async () => {
-      if (await this.applicationReady) await this.ctx.waterfall('hmr/before-reload', operation)
+      if (await this.applicationReady) await operation()
     })
   }
 
@@ -168,9 +162,9 @@ class Hmr extends Service {
     if (paths.some(path => this.configPaths.has(path))) throw new Error(`config path already registered: ${filename}`)
     for (const path of paths) this.configPaths.add(path)
     try {
-      const dispose = await watchExactConfig(
+      const dispose = await this.executing.exit(() => watchExactConfig(
         this.ownerContext, filename, this.config, () => this.runReload(refresh), () => this.executing.getStore() === true,
-      )
+      ))
       return async () => {
         await dispose()
         for (const path of paths) this.configPaths.delete(path)
@@ -218,23 +212,28 @@ class Hmr extends Service {
       const unsubscribe = ready.onReady(() => { started.resolve(true) })
       yield () => { unsubscribe(); started.resolve(false); return Promise.resolve() }
       const manifestPath = join(profile.dir, 'package.json')
-      this.ownerContext.on('hmr/before-reload', next => withFileLock(manifestPath, next))
-      const files = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME), manifestPath]
+      const patchFiles = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME)]
       let lastInputs: string | undefined
-      const refresh = async (): Promise<void> => {
-        const inputs = JSON.stringify(files.map((filename) => {
+      let lastBundles = JSON.stringify(profile.startedBundles)
+      const refresh = async (manifestOnly: boolean): Promise<void> => {
+        const bundles = JSON.stringify(readProfileManifest('dsh', profile.dir).dsh?.profile?.bundles ?? [])
+        if (manifestOnly && bundles === lastBundles) return
+        const inputs = JSON.stringify([bundles, ...patchFiles.map((filename) => {
           try { return readFileSync(filename, 'utf8') }
           catch (error) {
             if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
             throw error
           }
-        }))
+        })])
         if (inputs === lastInputs) return
+        const patches = readProfilePatches('dsh', profile)
+        const warnings = await reconcileProfilePatches(this.ownerContext.root, patches, 'dsh')
         lastInputs = inputs
-        const warnings = await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', profile), 'dsh')
+        lastBundles = bundles
         for (const diagnostic of warnings) this.ctx.logger.warn(diagnostic)
       }
-      for (const filename of files) await this.watchConfig(filename, refresh)
+      for (const filename of patchFiles) await this.watchConfig(filename, () => refresh(false))
+      await this.watchConfig(manifestPath, () => refresh(true))
     }
 
     const { loader } = this.ctx
@@ -292,17 +291,15 @@ class Hmr extends Service {
           else this.ctx.emit('hmr/change', url)
         }
         if (!fullReload && includes.size === 0 && this.stashed.size === 0) return
-        await this.ctx.waterfall('hmr/before-reload', async () => {
-          if (fullReload) {
-            loader.exit()
-            return
-          }
-          for (const include of includes) await include.refresh()
-          if (this.stashed.size > 0) {
-            try { await this.partialReload() } finally { this.stashed.clear() }
-          }
-          await loader.await()
-        })
+        if (fullReload) {
+          loader.exit()
+          return
+        }
+        for (const include of includes) await include.refresh()
+        if (this.stashed.size > 0) {
+          try { await this.partialReload() } finally { this.stashed.clear() }
+        }
+        await loader.await()
       }).catch((error: unknown) => { this.ctx.logger.warn(error) })
     }, this.config.debounce)
     this.watcher.on('change', (path) => { changed.add(path); dispatch() })

+ 11 - 24
packages/boot/hmr/tests/coordination.spec.ts

@@ -59,15 +59,11 @@ it('serializes mutations, rejects nesting and keeps the queue usable after failu
   expect(order).toEqual([1, 2])
 })
 
-it('holds configuration handlers behind mutations and awaits application locks', async () => {
-  const { hmr, ctx, dir } = await fixture()
+it('holds configuration handlers behind configuration mutations', async () => {
+  const { hmr, dir } = await fixture()
   const filename = join(dir, 'package.json')
   writeFileSync(filename, '{}')
   const order: string[] = []
-  ctx.on('hmr/before-reload', async (next) => {
-    order.push('lock')
-    try { await next() } finally { order.push('unlock') }
-  })
   const refreshed = Promise.withResolvers<undefined>()
   const dispose = await hmr.watchConfig(filename, async () => {
     order.push('refresh')
@@ -89,19 +85,19 @@ it('holds configuration handlers behind mutations and awaits application locks',
   await change
   await refreshed.promise
   await dispose()
-  expect(order).toEqual(['write', 'lock', 'refresh', 'unlock'])
+  expect(order).toEqual(['write', 'refresh'])
 })
 
-it('does not acquire a reload lock for an unrelated lock-file notification', async () => {
+it('reports unrelated lock-file notifications without reloading', async () => {
   const { ctx, dir, hmr } = await fixture()
-  const lock = vi.fn(async (next: () => Promise<void>) => { await next() })
-  ctx.on('hmr/before-reload', lock)
+  const loaded = vi.spyOn(ctx.loader, 'await')
+  onTestFinished(() => { loaded.mockRestore() })
   const observed = Promise.withResolvers<string>()
   ctx.on('hmr/change', (url) => { observed.resolve(url) })
   watchers.at(-1)!.emit('change', 'package.json.lock')
   expect(await observed.promise).toBe(pathToFileURL(join(realpathSync(dir), 'package.json.lock')).href)
   await hmr.runExclusive(async () => {})
-  expect(lock).not.toHaveBeenCalled()
+  expect(loaded).not.toHaveBeenCalled()
 })
 
 it('can dispose HMR from its own transaction without waiting on itself', async () => {
@@ -111,7 +107,7 @@ it('can dispose HMR from its own transaction without waiting on itself', async (
 })
 
 
-it('refreshes an Include under the application lock and skips registered exact paths', async () => {
+it('refreshes an Include through the queue and skips registered exact paths', async () => {
   const { ctx, dir, hmr } = await fixture()
   const moduleWatcher = watchers.at(-1)!
   const file = join(dir, 'nested.yml')
@@ -122,18 +118,9 @@ it('refreshes an Include under the application lock and skips registered exact p
   await ctx.loader.await()
   const include = ctx.loader.resolve(id).subtree as Include
   const refresh = vi.spyOn(include, 'refresh')
-  const order: string[] = []
-  const complete = Promise.withResolvers<undefined>()
-  ctx.on('hmr/before-reload', async (next) => {
-    order.push('lock')
-    await next()
-    order.push('unlock')
-    complete.resolve(undefined)
-  })
   moduleWatcher.emit('change', file)
-  await complete.promise
-  expect(refresh).toHaveBeenCalledOnce()
-  expect(order).toEqual(['lock', 'unlock'])
+  await vi.waitFor(() => { expect(refresh).toHaveBeenCalledOnce() })
+  await hmr.runExclusive(async () => {})
   const refreshed = Promise.withResolvers<undefined>()
   const registered = await hmr.watchConfig(file, async () => { refreshed.resolve(undefined) })
   const observed = Promise.withResolvers<string>()
@@ -148,7 +135,7 @@ it('refreshes an Include under the application lock and skips registered exact p
   expect(refresh).toHaveBeenCalledOnce()
 })
 
-it('queues cached module replacements behind package mutations and recovers after failure', async () => {
+it('queues cached module replacements behind configuration mutations and recovers after failure', async () => {
   const { ctx, dir, hmr } = await fixture()
   const file = join(dir, 'source.mjs')
   writeFileSync(file, 'export {}')

+ 53 - 17
packages/boot/hmr/tests/profile.spec.ts

@@ -1,4 +1,4 @@
-/** Profile watches share HMR's queue, readiness barrier and cross-process file lock. */
+/** Profile watches share HMR's queue and readiness barrier without waiting for package installation. */
 import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import { boot, initProfile, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
 import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import { FSWatcher } from 'chokidar'
 import { expect, it, onTestFinished, vi } from 'vitest'
@@ -23,7 +23,7 @@ vi.mock('chokidar', async (original) => {
   } }
 })
 
-async function fixture() {
+async function fixture(beforeWatch?: (profile: ProfileContext) => void) {
   const home = realpathSync(mkdtempSync(join(tmpdir(), 'hmr-profile-')))
   const dir = join(home, 'profiles', 'test')
   initProfile(dir, [])
@@ -41,6 +41,7 @@ async function fixture() {
   let commit: (() => void) | undefined
   const start = watchers.length
   const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (host) => {
+    beforeWatch?.(profile)
     host.provide('profileContext', profile)
     host.provide('appReady', { onReady(listener) { commit = listener; return () => { commit = undefined } } })
     host.loader.builtins.timer = Timer
@@ -77,7 +78,7 @@ it('waits for application readiness and applies profile, home and manifest chang
   expect(f.ctx.get('profileProbe')).toBe('home')
   rmSync(homePatch)
   const manifest = join(f.dir, 'package.json')
-  f.emit(2, manifest)
+  f.emit(1, homePatch)
   await f.drain()
   expect(f.ctx.get('profileProbe')).toBe('edited')
   f.emit(2, manifest)
@@ -85,22 +86,45 @@ it('waits for application readiness and applies profile, home and manifest chang
   expect(f.ctx.get('profileProbe')).toBe('edited')
 })
 
-it('does not reload while a CLI package operation owns the profile lock', async () => {
+it('applies configuration while a CLI operation holds the package writer lock', async () => {
+  const f = await fixture()
+  f.commit()
+  await withFileLock(join(f.dir, 'package.json'), async () => {
+    const original = readFileSync(f.profile.patchPath, 'utf8')
+    writeFileSync(f.profile.patchPath, original.replace('initial', 'during-install'))
+    f.emit(0, f.profile.patchPath)
+    await f.drain()
+    expect(f.ctx.get('profileProbe')).toBe('during-install')
+  })
+})
+
+it('ignores dependency-only manifest changes and reloads a changed bundle list', async () => {
   const f = await fixture()
   f.commit()
-  const entered = Promise.withResolvers<undefined>()
-  const release = Promise.withResolvers<undefined>()
-  onTestFinished(() => { release.resolve(undefined) })
-  const locked = withFileLock(join(f.dir, 'package.json'), async () => { entered.resolve(undefined); await release.promise })
-  await entered.promise
+  const include = [...f.ctx.loader.entries()].find(entry => entry.id === 'include')!
+  const update = vi.spyOn(include, 'update')
+  onTestFinished(() => { update.mockRestore() })
+  const manifestPath = join(f.dir, 'package.json')
+  const manifest = readProfileManifest('test', f.dir)
+  manifest.dependencies = { added: '1.0.0' }
+  delete manifest.dsh!.profile!.bundles
+  writeFileSync(manifestPath, JSON.stringify(manifest))
+  f.emit(2, manifestPath)
+  await f.drain()
+  expect(update).not.toHaveBeenCalled()
+  const packageDir = join(f.dir, 'node_modules', 'added')
+  mkdirSync(packageDir, { recursive: true })
+  writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'added', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+  writeFileSync(join(packageDir, 'cordis.patch.yml'), '- insert:\n    - id: bundled\n      name: cordis:probe\n      disabled: true\n')
+  manifest.dsh!.profile!.bundles = ['added']
+  writeFileSync(manifestPath, JSON.stringify(manifest))
+  f.emit(2, manifestPath)
+  await f.drain()
+  expect(update).toHaveBeenCalledOnce()
   f.emit(0, f.profile.patchPath)
-  let settled = false
-  const drained = f.drain().then(() => { settled = true })
-  await Promise.resolve(undefined)
-  expect(settled).toBe(false)
-  release.resolve(undefined)
-  await locked
-  await drained
+  await f.drain()
+  expect(update).toHaveBeenCalledOnce()
+  expect([...f.ctx.loader.entries()].some(entry => entry.id === 'include:bundled')).toBe(true)
 })
 
 it('cancels queued reloads when the application exits before readiness', async () => {
@@ -169,3 +193,15 @@ it('logs unchanged inactive entries while applying an unrelated file edit', asyn
   expect(f.ctx.get('profileProbe')).toBe('edited')
   expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing ('))
 })
+
+it('applies a patch edited after boot parsing but before watcher registration', async () => {
+  const f = await fixture((profile) => {
+    const original = readFileSync(profile.patchPath, 'utf8')
+    writeFileSync(profile.patchPath, original.replace('initial', 'edited-during-boot'))
+  })
+  expect(f.ctx.get('profileProbe')).toBe('initial')
+  f.commit()
+  f.emit(0, f.profile.patchPath)
+  await f.drain()
+  expect(f.ctx.get('profileProbe')).toBe('edited-during-boot')
+})

+ 17 - 0
packages/boot/hmr/tests/watch-config.spec.ts

@@ -155,6 +155,23 @@ describe('HMR exact config paths', () => {
     }
   })
 
+  it('processes native events for a watcher registered during a transaction', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-transaction-watch-'))
+    const filename = join(dir, 'plugins.yml')
+    onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
+    const ctx = await bootHmr(dir)
+    onTestFinished(() => ctx.fiber.dispose())
+    const hmr = ctx.hmr
+    const observed = Promise.withResolvers<string>()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((reason) => { observed.reject(new Error(String(reason))) })
+    onTestFinished(() => { warn.mockRestore() })
+    await hmr.runExclusive(() => hmr.watchConfig(filename, async () => {
+      observed.resolve(readFileSync(filename, 'utf8'))
+    }))
+    writeFileSync(filename, 'created-after-transaction')
+    expect(await observed.promise).toBe('created-after-transaction')
+  })
+
   it('serializes refreshes and waits for them during disposal', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
     hmrRoots.push(dir)

+ 0 - 3
packages/boot/hmr/tsconfig.json

@@ -28,9 +28,6 @@
     },
     {
       "path": "../cmdline"
-    },
-    {
-      "path": "../../util/atomic-write"
     }
   ]
 }

+ 2 - 2
packages/boot/plugin-manager/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/boot/plugin-manager/README.md
-README.md: 4b6fa13c938d93b9c5aebc8547f84c88d87771aa
-README.zh.md: 4b9ca9cbd5277a340073b82fcfb5931540624e93
+README.md: 55c1fc9dc875d68eeef4e959487fcc63654883de
+README.zh.md: 713eaff354e604232affaa35ea0e92b9894020e1

+ 2 - 2
packages/boot/plugin-manager/README.md

@@ -35,7 +35,7 @@ Enable the tool explicitly in the profile patch; agents using a preset also need
   disabled: false
 ```
 
-A plugin toggle writes only its `disabled` override in the profile's `cordis.patch.yml`. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
+A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
 
 ### Configuration
 
@@ -53,7 +53,7 @@ A plugin toggle writes only its `disabled` override in the profile's `cordis.pat
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. Package operations hold the profile manifest lock; file watchers read the completed state after its release.
+The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads.
 
 Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
 

+ 2 - 2
packages/boot/plugin-manager/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-reference"
   disabled: false
 ```
 
-插件开关只写入 profile 的 `cordis.patch.yml` 中的 `disabled` 覆盖项。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
+插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
 
 ### 配置
 
@@ -53,7 +53,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。包管理操作持有 profile manifest 锁;文件监听器在锁释放后读取完成的状态
+服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载
 
 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
 

+ 37 - 27
packages/boot/plugin-manager/src/index.ts

@@ -32,6 +32,8 @@ const protectedModules = new Set([
   '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
   '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
   '@deepseek-ai/dsh-client-ui-settings-plugin-inventory',
+  '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
+  '@deepseek-ai/dsh-api-remotes',
   '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
   '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
   '@deepseek-ai/dsh-hmr',
@@ -162,15 +164,15 @@ export class PluginManager extends TypertRemoteService {
    */
   @Remote
   setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult> {
-    return this.change(async (result) => {
+    return this.change(result => this.configure(async () => {
       const row = (await this.listPlugins()).find(item => item.entryId === id)
       if (row === undefined) throw new ManagementFailure('unknown-plugin')
       if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason)
-      await writePluginEnabled(this.profile.patchPath, row.patchId, enabled)
+      await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled)
       result.warnings = await this.reload(enabled ? [row.patchId] : [])
       const current = (await this.listPlugins()).find(item => item.entryId === id)
       return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined
-    }, { stage: 'enable', target: id, enabled })
+    }), { stage: 'enable', target: id, enabled })
   }
 
   /** Select or remove a bundle layer while retaining installed dependencies.
@@ -180,10 +182,10 @@ export class PluginManager extends TypertRemoteService {
    */
   @Remote
   setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult> {
-    return this.change(async (result) => {
+    return this.change(result => this.configure(async () => {
       await this.selectBundle(name, enabled)
       result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
-    }, { stage: 'enable', target: name, enabled })
+    }), { stage: 'enable', target: name, enabled })
   }
 
   /** Install a package using the same pnpm implementation as dsh plugin.
@@ -217,9 +219,11 @@ export class PluginManager extends TypertRemoteService {
       }
       result.target = name
       result.stage = 'enable'
-      if (options?.enabled !== false) await this.selectBundle(name, true)
-      if (Object.hasOwn(before, name)) return 'restart-required'
-      result.warnings = await this.reload()
+      return this.configure(async () => {
+        if (options?.enabled !== false) await this.selectBundle(name, true)
+        if (Object.hasOwn(before, name)) return 'restart-required'
+        if (options?.enabled !== false) result.warnings = await this.reload()
+      })
     }, { stage: 'install', target: spec, enabled: options?.enabled !== false })
   }
 
@@ -252,25 +256,26 @@ export class PluginManager extends TypertRemoteService {
   @Remote
   removeBundle(name: string): Promise<ChangeResult> {
     return this.change(async (result) => {
-      const bundle = (await this.listBundles()).find(item => item.name === name)
-      if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
-      if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
-        || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
-          .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
-        throw new ManagementFailure('stop-profile')
-      }
-      const contributions = bundle.error === undefined ? this.bundleRows(name) : []
-      if (bundle.enabled) {
-        await this.selectBundle(name, false)
-        result.warnings = await this.reload()
-      }
-      if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
-        && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
-        throw new ManagementFailure('bundle-in-use')
-      }
+      await this.configure(async () => {
+        const bundle = (await this.listBundles()).find(item => item.name === name)
+        if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
+        if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
+          || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
+            .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
+          throw new ManagementFailure('stop-profile')
+        }
+        const contributions = bundle.error === undefined ? this.bundleRows(name) : []
+        if (bundle.enabled) {
+          await this.selectBundle(name, false)
+          result.warnings = await this.reload()
+        }
+        if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
+          && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
+          throw new ManagementFailure('bundle-in-use')
+        }
+      })
       result.packageResult = await this.runPnpm(['remove', name])
       if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
-      result.warnings = await this.reload()
     }, { stage: 'remove', target: name })
   }
 
@@ -309,6 +314,12 @@ export class PluginManager extends TypertRemoteService {
     return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
   }
 
+  private configure<T>(operation: () => Promise<T>): Promise<T> {
+    const hmr = this.ownerContext.get('hmr')
+    const apply = () => { this.abort.signal.throwIfAborted(); return operation() }
+    return hmr === undefined ? apply() : hmr.runExclusive(apply)
+  }
+
   private async reload(requiredIds: readonly string[] = []): Promise<string[]> {
     if (this.ownerContext.get('hmr') === undefined) return []
     return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
@@ -334,8 +345,7 @@ export class PluginManager extends TypertRemoteService {
       notice = this.notify(result)
       return result
     }, { waitMs: this.lockWaitMs })
-    const hmr = this.ownerContext.get('hmr')
-    const result = await (hmr === undefined ? locked() : hmr.runExclusive(locked))
+    const result = await locked()
     await notice
     return result
   }

+ 7 - 3
packages/boot/plugin-manager/src/patch.ts

@@ -7,10 +7,11 @@ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 /** Replace the last matching override or append one after existing insertions.
  * @param filename Current profile patch file.
  * @param id Unique composition entry id.
+ * @param name Module name used to match name-qualified overrides.
  * @param enabled Desired entry enablement.
  * @returns Whether the file changed.
  */
-export async function writePluginEnabled(filename: string, id: string, enabled: boolean): Promise<boolean> {
+export async function writePluginEnabled(filename: string, id: string, name: string, enabled: boolean): Promise<boolean> {
   let text: string
   try {
     text = await readFile(filename, 'utf8')
@@ -26,8 +27,11 @@ export async function writePluginEnabled(filename: string, id: string, enabled:
   if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence')
   loadOptionalPatches('dsh', filename)
   const items = document.contents.items
-  const target = items.findLast((item, index) => isMap(item) && document.getIn([index, 'id']) === id
-    && !item.has('insert') && !item.has('name'))
+  const target = items.findLast((item, index) => {
+    if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false
+    const expectedName = document.getIn([index, 'name'])
+    return !expectedName || expectedName === name
+  })
   if (isMap(target)) {
     if (document.getIn([items.indexOf(target), 'disabled']) === !enabled) return false
     document.setIn([items.indexOf(target), 'disabled'], !enabled)

+ 57 - 2
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -8,7 +8,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { UserMessage } from '@deepseek-ai/dsh-llm'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import {
-  boot, composeEntries, initProfile, readProfilePatches, readProfileManifest,
+  boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches,
   type ProfileContext,
 } from '@deepseek-ai/dsh-app-boot'
 import PluginManager, { type Config } from '../src/index.ts'
@@ -142,7 +142,9 @@ it('installs only valid bundle declarations and honors installation without acti
 it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
   const { manager, dir, ctx } = await fixture()
   const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
-    expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
+    await ctx.hmr.runExclusive(async () => {
+      expect([...ctx.loader.entries()].some(row => row.id === 'include:managed')).toBe(false)
+    })
     return { exitCode: 1, output: 'removal failed', truncated: false, logPath: join(dir, 'pnpm.log') }
   })
   onTestFinished(() => { remove.mockRestore() })
@@ -232,6 +234,34 @@ it('refuses management bundle disablement and permits repeated bundle selections
   expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ application: 'applied', changed: false })
 })
 
+it.each([
+  '@deepseek-ai/dsh-host-plugin-inventory',
+  '@deepseek-ai/dsh-typert-registry',
+  '@deepseek-ai/dsh-api-remotes',
+])('protects the management dependency %s and its containing bundle', async (name) => {
+  const { ctx, manager, bundle, profile, dir } = await fixture('startup')
+  bundle('extra', [{ id: 'dependency', name, disabled: true }])
+  await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
+  const entry = (await manager.listPlugins()).find(row => row.moduleName === name)!
+  expect(entry).toMatchObject({ readOnlyReason: 'management-required' })
+  const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
+  const patch = readFileSync(profile.patchPath, 'utf8')
+  expect(await manager.setPluginEnabled(entry.entryId, false)).toMatchObject({
+    changed: false, application: 'failed', error: { code: 'management-required' },
+  })
+  expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
+    removable: false, readOnlyReason: 'management-required',
+  })
+  expect(await manager.setBundleEnabled('extra', false)).toMatchObject({
+    changed: false, application: 'failed', error: { code: 'management-required' },
+  })
+  expect(await manager.removeBundle('extra')).toMatchObject({
+    changed: false, application: 'failed', error: { code: 'not-removable' },
+  })
+  expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
+  expect(readFileSync(profile.patchPath, 'utf8')).toBe(patch)
+})
+
 it('addresses children inside profile groups and marks ambiguous ids read-only', async () => {
   const { manager, bundle, profile } = await fixture('live', false, (ctx) => { ctx.loader.builtins.group = Group })
   bundle('grouped', [{ id: 'group', name: 'cordis:group', group: true,
@@ -458,3 +488,28 @@ it('does not delete a bundle retained by a higher-priority overlay', async () =>
   expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
   expect(ctx.get('managedProbe')).toBe(true)
 })
+
+it('applies watched configuration while pnpm installation is still running', async () => {
+  const { ctx, manager, dir, profile, bundle } = await fixture()
+  const entered = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<undefined>()
+  const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
+    entered.resolve(undefined)
+    await release.promise
+    bundle('new-bundle', [])
+    const manifest = readProfileManifest('test', dir)
+    manifest.dependencies = { ...manifest.dependencies, 'new-bundle': '1.0.0' }
+    writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+    return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
+  })
+  const installing = manager.installBundle('new-bundle')
+  onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() })
+  await entered.promise
+  writeFileSync(profile.patchPath, '- id: managed\n  disabled: true\n')
+  await vi.waitFor(() => { expect(ctx.get('managedProbe')).toBeUndefined() }, { timeout: 10000 })
+  expect(pnpm).toHaveBeenCalledOnce()
+  release.resolve(undefined)
+  expect(await installing).toMatchObject({ application: 'applied', changed: true })
+  expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
+  expect(ctx.get('managedProbe')).toBeUndefined()
+})

+ 30 - 9
packages/boot/plugin-manager/tests/patch.spec.ts

@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { expect, it, onTestFinished } from 'vitest'
+import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
 import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot'
 import { writePluginEnabled } from '../src/patch.ts'
 
@@ -16,7 +17,7 @@ async function fixture(text?: string): Promise<string> {
 
 it('preserves comments, expressions, unrelated configuration and the last override', async () => {
   const file = await fixture('# personal configuration\n- id: tool\n  config:\n    value: !!js process.platform\n- id: tool\n  disabled: false # availability\n')
-  expect(await writePluginEnabled(file, 'tool', false)).toBe(true)
+  expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true)
   const text = await readFile(file, 'utf8')
   expect(text).toContain('# personal configuration')
   expect(text).toContain('!!js process.platform')
@@ -24,17 +25,17 @@ it('preserves comments, expressions, unrelated configuration and the last overri
   expect(loadOptionalPatches('test', file)).toEqual([
     { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true },
   ])
-  expect(await writePluginEnabled(file, 'tool', false)).toBe(false)
+  expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false)
   expect(await readFile(file, 'utf8')).toBe(text)
-  expect(await writePluginEnabled(file, 'tool', true)).toBe(true)
+  expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true)
 })
 
 it('creates a missing patch file and appends after insertions', async () => {
   const file = await fixture()
-  await writePluginEnabled(file, 'tool', false)
+  await writePluginEnabled(file, 'tool', 'package', false)
   expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }])
   await writeFile(file, '- insert:\n    - id: tool\n      name: package\n')
-  await writePluginEnabled(file, 'tool', true)
+  await writePluginEnabled(file, 'tool', 'package', true)
   expect(loadOptionalPatches('test', file)).toEqual([
     { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: false },
   ])
@@ -42,22 +43,42 @@ it('creates a missing patch file and appends after insertions', async () => {
 
 it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => {
   const file = await fixture(text)
-  await expect(writePluginEnabled(file, 'tool', true)).rejects.toThrow()
+  await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow()
   expect(await readFile(file, 'utf8')).toBe(text)
 })
 
 
 it('retains name-asserting overrides and appends an unambiguous switch', async () => {
   const file = await fixture('- id: tool\n  name: another-package\n  disabled: false\n')
-  await writePluginEnabled(file, 'tool', false)
+  await writePluginEnabled(file, 'tool', 'package', false)
   expect(loadOptionalPatches('test', file)).toEqual([
     { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true },
   ])
-  expect(await writePluginEnabled(file, 'tool', false)).toBe(false)
+  expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false)
 })
 
 it('reports read failures without replacing a directory with configuration', async () => {
   const file = await fixture()
   await mkdir(file)
-  await expect(writePluginEnabled(file, 'tool', true)).rejects.toThrow()
+  await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow()
+})
+
+it('updates the last matching named override and leaves mismatched names untouched', async () => {
+  const file = await fixture('- id: tool\n  disabled: true\n- id: tool\n  name: package\n  config:\n    value: !!js process.platform\n  disabled: true # availability\n- id: tool\n  name: another-package\n  disabled: true\n')
+  expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true)
+  const text = await readFile(file, 'utf8')
+  expect(text).toContain('!!js process.platform')
+  expect(text).toContain('# availability')
+  const patches = loadOptionalPatches('test', file)
+  expect(patches).toEqual([
+    { id: 'tool', disabled: true },
+    { id: 'tool', name: 'package', config: { value: { __jsExpr: 'process.platform' } }, disabled: false },
+    { id: 'tool', name: 'another-package', disabled: true },
+  ])
+  expect(applyEntryPatches([{ id: 'tool', name: 'package' }], patches, () => {}))
+    .toMatchObject([{ id: 'tool', name: 'package', disabled: false }])
+  expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(false)
+  expect(await readFile(file, 'utf8')).toBe(text)
+  expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true)
+  expect(loadOptionalPatches('test', file)).toHaveLength(3)
 })

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

@@ -3573,14 +3573,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Goal mutation accepted by one live agent. The matching `goal/change` session event has already committed. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.',
     parameters: [{ name: 'payload', description: '.change - fresh current projection or clear tombstone.' }],
   },
-  {
-    name: 'hmr/before-reload',
-    mode: 'waterfall',
-    signature: '\'hmr/before-reload\'(next: () => Promise<void>): Promise<void>',
-    summary: 'Acquire application-owned exclusion before an automatic reload.',
-    description: 'Acquire application-owned exclusion before an automatic reload.',
-    parameters: [{ name: 'next', description: 'Runs the remaining lock providers and reload; listeners must await it.' }],
-  },
   {
     name: 'hmr/change',
     mode: 'emit',

+ 3 - 3
pnpm-lock.yaml

@@ -1459,9 +1459,6 @@ importers:
       '@babel/code-frame':
         specifier: ^7.29.0
         version: 7.29.7
-      '@deepseek-ai/dsh-atomic-write':
-        specifier: workspace:^
-        version: link:../../util/atomic-write
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -1487,6 +1484,9 @@ importers:
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../app-boot
+      '@deepseek-ai/dsh-atomic-write':
+        specifier: workspace:^
+        version: link:../../util/atomic-write
       '@deepseek-ai/dsh-cmdline':
         specifier: workspace:^
         version: link:../cmdline